8 var UIElement = L.Class.extend({
10 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
11 return this.node.value;
16 setValue: function(value) {
17 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
18 this.node.value = value;
25 registerEvents: function(targetNode, synevent, events) {
26 var dispatchFn = L.bind(function(ev) {
27 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
30 for (var i = 0; i < events.length; i++)
31 targetNode.addEventListener(events[i], dispatchFn);
34 setUpdateEvents: function(targetNode /*, ... */) {
35 this.registerEvents(targetNode, 'widget-update', this.varargs(arguments, 1));
38 setChangeEvents: function(targetNode /*, ... */) {
39 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
43 var UIDropdown = UIElement.extend({
44 __init__: function(value, choices, options) {
45 if (typeof(choices) != 'object')
48 if (!Array.isArray(value))
49 this.values = (value != null) ? [ value ] : [];
53 this.choices = choices;
54 this.options = Object.assign({
56 multi: Array.isArray(value),
58 select_placeholder: _('-- Please choose --'),
59 custom_placeholder: _('-- custom --'),
63 create_query: '.create-item-input',
64 create_template: 'script[type="item-template"]'
70 'id': this.options.id,
71 'class': 'cbi-dropdown',
72 'multiple': this.options.multi ? '' : null,
73 'optional': this.options.optional ? '' : null,
76 var keys = Object.keys(this.choices);
78 if (this.options.sort === true)
80 else if (Array.isArray(this.options.sort))
81 keys = this.options.sort;
83 if (this.options.create)
84 for (var i = 0; i < this.values.length; i++)
85 if (!this.choices.hasOwnProperty(this.values[i]))
86 keys.push(this.values[i]);
88 for (var i = 0; i < keys.length; i++)
89 sb.lastElementChild.appendChild(E('li', {
90 'data-value': keys[i],
91 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
92 }, this.choices[keys[i]] || keys[i]));
94 if (this.options.create) {
95 var createEl = E('input', {
97 'class': 'create-item-input',
98 'placeholder': this.options.custom_placeholder || this.options.placeholder
101 if (this.options.datatype)
102 L.ui.addValidator(createEl, this.options.datatype, true, 'blur', 'keyup');
104 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
107 return this.bind(sb);
111 var o = this.options;
113 o.multi = sb.hasAttribute('multiple');
114 o.optional = sb.hasAttribute('optional');
115 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
116 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
117 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
118 o.create_query = sb.getAttribute('item-create') || o.create_query;
119 o.create_template = sb.getAttribute('item-template') || o.create_template;
121 var ul = sb.querySelector('ul'),
122 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
123 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
124 canary = sb.appendChild(E('div')),
125 create = sb.querySelector(this.options.create_query),
126 ndisplay = this.options.display_items,
129 if (this.options.multi) {
130 var items = ul.querySelectorAll('li');
132 for (var i = 0; i < items.length; i++) {
133 this.transformItem(sb, items[i]);
135 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
136 items[i].setAttribute('display', n++);
140 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
141 var placeholder = E('li', { placeholder: '' },
142 this.options.select_placeholder || this.options.placeholder);
145 ? ul.insertBefore(placeholder, ul.firstChild)
146 : ul.appendChild(placeholder);
149 var items = ul.querySelectorAll('li'),
150 sel = sb.querySelectorAll('[selected]');
152 sel.forEach(function(s) {
153 s.removeAttribute('selected');
156 var s = sel[0] || items[0];
158 s.setAttribute('selected', '');
159 s.setAttribute('display', n++);
165 this.saveValues(sb, ul);
167 ul.setAttribute('tabindex', -1);
168 sb.setAttribute('tabindex', 0);
171 sb.setAttribute('more', '')
173 sb.removeAttribute('more');
175 if (ndisplay == this.options.display_items)
176 sb.setAttribute('empty', '')
178 sb.removeAttribute('empty');
180 more.innerHTML = (ndisplay == this.options.display_items)
181 ? (this.options.select_placeholder || this.options.placeholder) : '···';
184 sb.addEventListener('click', this.handleClick.bind(this));
185 sb.addEventListener('keydown', this.handleKeydown.bind(this));
186 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
187 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
189 if ('ontouchstart' in window) {
190 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
191 window.addEventListener('touchstart', this.closeAllDropdowns);
194 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
195 sb.addEventListener('focus', this.handleFocus.bind(this));
197 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
199 window.addEventListener('mouseover', this.setFocus);
200 window.addEventListener('click', this.closeAllDropdowns);
204 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
205 create.addEventListener('focus', this.handleCreateFocus.bind(this));
206 create.addEventListener('blur', this.handleCreateBlur.bind(this));
208 var li = findParent(create, 'li');
210 li.setAttribute('unselectable', '');
211 li.addEventListener('click', this.handleCreateClick.bind(this));
216 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
217 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
219 L.dom.bindClassInstance(sb, this);
224 openDropdown: function(sb) {
225 var st = window.getComputedStyle(sb, null),
226 ul = sb.querySelector('ul'),
227 li = ul.querySelectorAll('li'),
228 fl = findParent(sb, '.cbi-value-field'),
229 sel = ul.querySelector('[selected]'),
230 rect = sb.getBoundingClientRect(),
231 items = Math.min(this.options.dropdown_items, li.length);
233 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
234 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
237 sb.setAttribute('open', '');
239 var pv = ul.cloneNode(true);
240 pv.classList.add('preview');
243 fl.classList.add('cbi-dropdown-open');
245 if ('ontouchstart' in window) {
246 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
247 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
248 scrollFrom = window.pageYOffset,
249 scrollTo = scrollFrom + rect.top - vpHeight * 0.5,
252 ul.style.top = sb.offsetHeight + 'px';
253 ul.style.left = -rect.left + 'px';
254 ul.style.right = (rect.right - vpWidth) + 'px';
255 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
256 ul.style.WebkitOverflowScrolling = 'touch';
258 var scrollStep = function(timestamp) {
261 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
264 var duration = Math.max(timestamp - start, 1);
265 if (duration < 100) {
266 document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
267 window.requestAnimationFrame(scrollStep);
270 document.body.scrollTop = scrollTo;
274 window.requestAnimationFrame(scrollStep);
277 ul.style.maxHeight = '1px';
278 ul.style.top = ul.style.bottom = '';
280 window.requestAnimationFrame(function() {
281 var height = items * li[Math.max(0, li.length - 2)].offsetHeight;
283 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
284 ul.style[((rect.top + rect.height + height) > window.innerHeight) ? 'bottom' : 'top'] = rect.height + 'px';
285 ul.style.maxHeight = height + 'px';
289 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
290 for (var i = 0; i < cboxes.length; i++) {
291 cboxes[i].checked = true;
292 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
295 ul.classList.add('dropdown');
297 sb.insertBefore(pv, ul.nextElementSibling);
299 li.forEach(function(l) {
300 l.setAttribute('tabindex', 0);
303 sb.lastElementChild.setAttribute('tabindex', 0);
305 this.setFocus(sb, sel || li[0], true);
308 closeDropdown: function(sb, no_focus) {
309 if (!sb.hasAttribute('open'))
312 var pv = sb.querySelector('ul.preview'),
313 ul = sb.querySelector('ul.dropdown'),
314 li = ul.querySelectorAll('li'),
315 fl = findParent(sb, '.cbi-value-field');
317 li.forEach(function(l) { l.removeAttribute('tabindex'); });
318 sb.lastElementChild.removeAttribute('tabindex');
321 sb.removeAttribute('open');
322 sb.style.width = sb.style.height = '';
324 ul.classList.remove('dropdown');
325 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
328 fl.classList.remove('cbi-dropdown-open');
331 this.setFocus(sb, sb);
333 this.saveValues(sb, ul);
336 toggleItem: function(sb, li, force_state) {
337 if (li.hasAttribute('unselectable'))
340 if (this.options.multi) {
341 var cbox = li.querySelector('input[type="checkbox"]'),
342 items = li.parentNode.querySelectorAll('li'),
343 label = sb.querySelector('ul.preview'),
344 sel = li.parentNode.querySelectorAll('[selected]').length,
345 more = sb.querySelector('.more'),
346 ndisplay = this.options.display_items,
349 if (li.hasAttribute('selected')) {
350 if (force_state !== true) {
351 if (sel > 1 || this.options.optional) {
352 li.removeAttribute('selected');
353 cbox.checked = cbox.disabled = false;
357 cbox.disabled = true;
362 if (force_state !== false) {
363 li.setAttribute('selected', '');
365 cbox.disabled = false;
370 while (label && label.firstElementChild)
371 label.removeChild(label.firstElementChild);
373 for (var i = 0; i < items.length; i++) {
374 items[i].removeAttribute('display');
375 if (items[i].hasAttribute('selected')) {
376 if (ndisplay-- > 0) {
377 items[i].setAttribute('display', n++);
379 label.appendChild(items[i].cloneNode(true));
381 var c = items[i].querySelector('input[type="checkbox"]');
383 c.disabled = (sel == 1 && !this.options.optional);
388 sb.setAttribute('more', '');
390 sb.removeAttribute('more');
392 if (ndisplay === this.options.display_items)
393 sb.setAttribute('empty', '');
395 sb.removeAttribute('empty');
397 more.innerHTML = (ndisplay === this.options.display_items)
398 ? (this.options.select_placeholder || this.options.placeholder) : '···';
401 var sel = li.parentNode.querySelector('[selected]');
403 sel.removeAttribute('display');
404 sel.removeAttribute('selected');
407 li.setAttribute('display', 0);
408 li.setAttribute('selected', '');
410 this.closeDropdown(sb, true);
413 this.saveValues(sb, li.parentNode);
416 transformItem: function(sb, li) {
417 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
420 while (li.firstChild)
421 label.appendChild(li.firstChild);
423 li.appendChild(cbox);
424 li.appendChild(label);
427 saveValues: function(sb, ul) {
428 var sel = ul.querySelectorAll('li[selected]'),
429 div = sb.lastElementChild,
430 name = this.options.name,
434 while (div.lastElementChild)
435 div.removeChild(div.lastElementChild);
437 sel.forEach(function (s) {
438 if (s.hasAttribute('placeholder'))
443 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
447 div.appendChild(E('input', {
455 strval += strval.length ? ' ' + v.value : v.value;
463 if (this.options.multi)
464 detail.values = values;
466 detail.value = values.length ? values[0] : null;
470 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
476 setValues: function(sb, values) {
477 var ul = sb.querySelector('ul');
479 if (this.options.create) {
480 for (var value in values) {
481 this.createItems(sb, value);
483 if (!this.options.multi)
488 if (this.options.multi) {
489 var lis = ul.querySelectorAll('li[data-value]');
490 for (var i = 0; i < lis.length; i++) {
491 var value = lis[i].getAttribute('data-value');
492 if (values === null || !(value in values))
493 this.toggleItem(sb, lis[i], false);
495 this.toggleItem(sb, lis[i], true);
499 var ph = ul.querySelector('li[placeholder]');
501 this.toggleItem(sb, ph);
503 var lis = ul.querySelectorAll('li[data-value]');
504 for (var i = 0; i < lis.length; i++) {
505 var value = lis[i].getAttribute('data-value');
506 if (values !== null && (value in values))
507 this.toggleItem(sb, lis[i]);
512 setFocus: function(sb, elem, scroll) {
513 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
516 if (sb.target && findParent(sb.target, 'ul.dropdown'))
519 document.querySelectorAll('.focus').forEach(function(e) {
520 if (!matchesElem(e, 'input')) {
521 e.classList.remove('focus');
528 elem.classList.add('focus');
531 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
535 createItems: function(sb, value) {
537 val = (value || '').trim(),
538 ul = sb.querySelector('ul');
540 if (!sbox.options.multi)
541 val = val.length ? [ val ] : [];
543 val = val.length ? val.split(/\s+/) : [];
545 val.forEach(function(item) {
548 ul.childNodes.forEach(function(li) {
549 if (li.getAttribute && li.getAttribute('data-value') === item)
555 tpl = sb.querySelector(sbox.options.create_template);
558 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
560 markup = '<li data-value="{{value}}">{{value}}</li>';
562 new_item = E(markup.replace(/{{value}}/g, item));
564 if (sbox.options.multi) {
565 sbox.transformItem(sb, new_item);
568 var old = ul.querySelector('li[created]');
572 new_item.setAttribute('created', '');
575 new_item = ul.insertBefore(new_item, ul.lastElementChild);
578 sbox.toggleItem(sb, new_item, true);
579 sbox.setFocus(sb, new_item, true);
583 closeAllDropdowns: function() {
584 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
585 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
589 handleClick: function(ev) {
590 var sb = ev.currentTarget;
592 if (!sb.hasAttribute('open')) {
593 if (!matchesElem(ev.target, 'input'))
594 this.openDropdown(sb);
597 var li = findParent(ev.target, 'li');
598 if (li && li.parentNode.classList.contains('dropdown'))
599 this.toggleItem(sb, li);
600 else if (li && li.parentNode.classList.contains('preview'))
601 this.closeDropdown(sb);
605 ev.stopPropagation();
608 handleKeydown: function(ev) {
609 var sb = ev.currentTarget;
611 if (matchesElem(ev.target, 'input'))
614 if (!sb.hasAttribute('open')) {
615 switch (ev.keyCode) {
620 this.openDropdown(sb);
625 var active = findParent(document.activeElement, 'li');
627 switch (ev.keyCode) {
629 this.closeDropdown(sb);
634 if (!active.hasAttribute('selected'))
635 this.toggleItem(sb, active);
636 this.closeDropdown(sb);
643 this.toggleItem(sb, active);
649 if (active && active.previousElementSibling) {
650 this.setFocus(sb, active.previousElementSibling);
656 if (active && active.nextElementSibling) {
657 this.setFocus(sb, active.nextElementSibling);
665 handleDropdownClose: function(ev) {
666 var sb = ev.currentTarget;
668 this.closeDropdown(sb, true);
671 handleDropdownSelect: function(ev) {
672 var sb = ev.currentTarget,
673 li = findParent(ev.target, 'li');
678 this.toggleItem(sb, li);
679 this.closeDropdown(sb, true);
682 handleMouseover: function(ev) {
683 var sb = ev.currentTarget;
685 if (!sb.hasAttribute('open'))
688 var li = findParent(ev.target, 'li');
690 if (li && li.parentNode.classList.contains('dropdown'))
691 this.setFocus(sb, li);
694 handleFocus: function(ev) {
695 var sb = ev.currentTarget;
697 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
698 if (s !== sb || sb.hasAttribute('open'))
699 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
703 handleCanaryFocus: function(ev) {
704 this.closeDropdown(ev.currentTarget.parentNode);
707 handleCreateKeydown: function(ev) {
708 var input = ev.currentTarget,
709 sb = findParent(input, '.cbi-dropdown');
711 switch (ev.keyCode) {
715 if (input.classList.contains('cbi-input-invalid'))
718 this.createItems(sb, input.value);
725 handleCreateFocus: function(ev) {
726 var input = ev.currentTarget,
727 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
728 sb = findParent(input, '.cbi-dropdown');
733 sb.setAttribute('locked-in', '');
736 handleCreateBlur: function(ev) {
737 var input = ev.currentTarget,
738 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
739 sb = findParent(input, '.cbi-dropdown');
742 cbox.checked = false;
744 sb.removeAttribute('locked-in');
747 handleCreateClick: function(ev) {
748 ev.currentTarget.querySelector(this.options.create_query).focus();
751 setValue: function(values) {
752 if (this.options.multi) {
753 if (!Array.isArray(values))
754 values = (values != null) ? [ values ] : [];
758 for (var i = 0; i < values.length; i++)
761 this.setValues(this.node, v);
766 if (values != null) {
767 if (Array.isArray(values))
773 this.setValues(this.node, v);
777 getValue: function() {
778 var div = this.node.lastElementChild,
779 h = div.querySelectorAll('input[type="hidden"]'),
782 for (var i = 0; i < h.length; i++)
785 return this.options.multi ? v : v[0];
789 var UICombobox = UIDropdown.extend({
790 __init__: function(value, choices, options) {
791 this.super('__init__', [ value, choices, Object.assign({
792 select_placeholder: _('-- Please choose --'),
793 custom_placeholder: _('-- custom --'),
804 var UIDynamicList = UIElement.extend({
805 __init__: function(values, choices, options) {
806 if (!Array.isArray(values))
807 values = (values != null) ? [ values ] : [];
809 if (typeof(choices) != 'object')
812 this.values = values;
813 this.choices = choices;
814 this.options = Object.assign({}, options, {
822 'id': this.options.id,
823 'class': 'cbi-dynlist'
824 }, E('div', { 'class': 'add-item' }));
827 var cbox = new UICombobox(null, this.choices, this.options);
828 dl.lastElementChild.appendChild(cbox.render());
831 var inputEl = E('input', {
833 'class': 'cbi-input-text',
834 'placeholder': this.options.placeholder
837 dl.lastElementChild.appendChild(inputEl);
838 dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
840 L.ui.addValidator(inputEl, this.options.datatype, true, 'blue', 'keyup');
843 for (var i = 0; i < this.values.length; i++)
844 this.addItem(dl, this.values[i],
845 this.choices ? this.choices[this.values[i]] : null);
847 return this.bind(dl);
851 dl.addEventListener('click', L.bind(this.handleClick, this));
852 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
853 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
857 this.setUpdateEvents(dl, 'cbi-dynlist-change');
858 this.setChangeEvents(dl, 'cbi-dynlist-change');
860 L.dom.bindClassInstance(dl, this);
865 addItem: function(dl, value, text, flash) {
867 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
868 E('span', {}, text || value),
871 'name': this.options.name,
874 dl.querySelectorAll('.item, .add-item').forEach(function(item) {
878 var hidden = item.querySelector('input[type="hidden"]');
880 if (hidden && hidden.parentNode !== item)
883 if (hidden && hidden.value === value)
885 else if (!hidden || hidden.value >= value)
886 exists = !!item.parentNode.insertBefore(new_item, item);
889 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
900 removeItem: function(dl, item) {
901 var value = item.querySelector('input[type="hidden"]').value;
902 var sb = dl.querySelector('.cbi-dropdown');
904 sb.querySelectorAll('ul > li').forEach(function(li) {
905 if (li.getAttribute('data-value') === value) {
906 if (li.hasAttribute('dynlistcustom'))
907 li.parentNode.removeChild(li);
909 li.removeAttribute('unselectable');
913 item.parentNode.removeChild(item);
915 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
926 handleClick: function(ev) {
927 var dl = ev.currentTarget,
928 item = findParent(ev.target, '.item');
931 this.removeItem(dl, item);
933 else if (matchesElem(ev.target, '.cbi-button-add')) {
934 var input = ev.target.previousElementSibling;
935 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
936 this.addItem(dl, input.value, null, true);
942 handleDropdownChange: function(ev) {
943 var dl = ev.currentTarget,
944 sbIn = ev.detail.instance,
945 sbEl = ev.detail.element,
946 sbVal = ev.detail.value;
951 sbIn.setValues(sbEl, null);
952 sbVal.element.setAttribute('unselectable', '');
954 if (sbVal.element.hasAttribute('created')) {
955 sbVal.element.removeAttribute('created');
956 sbVal.element.setAttribute('dynlistcustom', '');
959 this.addItem(dl, sbVal.value, sbVal.text, true);
962 handleKeydown: function(ev) {
963 var dl = ev.currentTarget,
964 item = findParent(ev.target, '.item');
967 switch (ev.keyCode) {
968 case 8: /* backspace */
969 if (item.previousElementSibling)
970 item.previousElementSibling.focus();
972 this.removeItem(dl, item);
975 case 46: /* delete */
976 if (item.nextElementSibling) {
977 if (item.nextElementSibling.classList.contains('item'))
978 item.nextElementSibling.focus();
980 item.nextElementSibling.firstElementChild.focus();
983 this.removeItem(dl, item);
987 else if (matchesElem(ev.target, '.cbi-input-text')) {
988 switch (ev.keyCode) {
990 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
991 this.addItem(dl, ev.target.value, null, true);
992 ev.target.value = '';
1003 getValue: function() {
1004 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
1007 for (var i = 0; i < items.length; i++)
1008 v.push(items[i].value);
1013 setValue: function(values) {
1014 if (!Array.isArray(values))
1015 values = (values != null) ? [ values ] : [];
1017 var items = this.node.querySelectorAll('.item');
1019 for (var i = 0; i < items.length; i++)
1020 if (items[i].parentNode === this.node)
1021 this.removeItem(this.node, items[i]);
1023 for (var i = 0; i < values.length; i++)
1024 this.addItem(this.node, values[i],
1025 this.choices ? this.choices[values[i]] : null);
1030 return L.Class.extend({
1031 __init__: function() {
1032 modalDiv = document.body.appendChild(
1033 L.dom.create('div', { id: 'modal_overlay' },
1034 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
1036 tooltipDiv = document.body.appendChild(
1037 L.dom.create('div', { class: 'cbi-tooltip' }));
1039 /* setup old aliases */
1040 L.showModal = this.showModal;
1041 L.hideModal = this.hideModal;
1042 L.showTooltip = this.showTooltip;
1043 L.hideTooltip = this.hideTooltip;
1044 L.itemlist = this.itemlist;
1046 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
1047 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
1048 document.addEventListener('focus', this.showTooltip.bind(this), true);
1049 document.addEventListener('blur', this.hideTooltip.bind(this), true);
1051 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
1052 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
1053 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
1057 showModal: function(title, children) {
1058 var dlg = modalDiv.firstElementChild;
1060 dlg.setAttribute('class', 'modal');
1062 L.dom.content(dlg, L.dom.create('h4', {}, title));
1063 L.dom.append(dlg, children);
1065 document.body.classList.add('modal-overlay-active');
1070 hideModal: function() {
1071 document.body.classList.remove('modal-overlay-active');
1075 showTooltip: function(ev) {
1076 var target = findParent(ev.target, '[data-tooltip]');
1081 if (tooltipTimeout !== null) {
1082 window.clearTimeout(tooltipTimeout);
1083 tooltipTimeout = null;
1086 var rect = target.getBoundingClientRect(),
1087 x = rect.left + window.pageXOffset,
1088 y = rect.top + rect.height + window.pageYOffset;
1090 tooltipDiv.className = 'cbi-tooltip';
1091 tooltipDiv.innerHTML = '▲ ';
1092 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
1094 if (target.hasAttribute('data-tooltip-style'))
1095 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
1097 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
1098 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
1099 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
1102 tooltipDiv.style.top = y + 'px';
1103 tooltipDiv.style.left = x + 'px';
1104 tooltipDiv.style.opacity = 1;
1106 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
1108 detail: { target: target }
1112 hideTooltip: function(ev) {
1113 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
1114 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
1117 if (tooltipTimeout !== null) {
1118 window.clearTimeout(tooltipTimeout);
1119 tooltipTimeout = null;
1122 tooltipDiv.style.opacity = 0;
1123 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
1125 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
1129 itemlist: function(node, items, separators) {
1132 if (!Array.isArray(separators))
1133 separators = [ separators || E('br') ];
1135 for (var i = 0; i < items.length; i += 2) {
1136 if (items[i+1] !== null && items[i+1] !== undefined) {
1137 var sep = separators[(i/2) % separators.length],
1140 children.push(E('span', { class: 'nowrap' }, [
1141 items[i] ? E('strong', items[i] + ': ') : '',
1145 if ((i+2) < items.length)
1146 children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
1150 L.dom.content(node, children);
1156 tabs: L.Class.singleton({
1158 var groups = [], prevGroup = null, currGroup = null;
1160 document.querySelectorAll('[data-tab]').forEach(function(tab) {
1161 var parent = tab.parentNode;
1163 if (!parent.hasAttribute('data-tab-group'))
1164 parent.setAttribute('data-tab-group', groups.length);
1166 currGroup = +parent.getAttribute('data-tab-group');
1168 if (currGroup !== prevGroup) {
1169 prevGroup = currGroup;
1171 if (!groups[currGroup])
1172 groups[currGroup] = [];
1175 groups[currGroup].push(tab);
1178 for (var i = 0; i < groups.length; i++)
1179 this.initTabGroup(groups[i]);
1181 document.addEventListener('dependency-update', this.updateTabs.bind(this));
1186 this.setActiveTabId(-1, -1);
1189 initTabGroup: function(panes) {
1190 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
1193 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
1194 group = panes[0].parentNode,
1195 groupId = +group.getAttribute('data-tab-group'),
1198 for (var i = 0, pane; pane = panes[i]; i++) {
1199 var name = pane.getAttribute('data-tab'),
1200 title = pane.getAttribute('data-tab-title'),
1201 active = pane.getAttribute('data-tab-active') === 'true';
1203 menu.appendChild(E('li', {
1204 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
1208 'click': this.switchTab.bind(this)
1215 group.parentNode.insertBefore(menu, group);
1217 if (selected === null) {
1218 selected = this.getActiveTabId(groupId);
1220 if (selected < 0 || selected >= panes.length)
1223 menu.childNodes[selected].classList.add('cbi-tab');
1224 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
1225 panes[selected].setAttribute('data-tab-active', 'true');
1227 this.setActiveTabId(groupId, selected);
1231 getActiveTabState: function() {
1232 var page = document.body.getAttribute('data-page');
1235 var val = JSON.parse(window.sessionStorage.getItem('tab'));
1236 if (val.page === page && Array.isArray(val.groups))
1241 window.sessionStorage.removeItem('tab');
1242 return { page: page, groups: [] };
1245 getActiveTabId: function(groupId) {
1246 return +this.getActiveTabState().groups[groupId] || 0;
1249 setActiveTabId: function(groupId, tabIndex) {
1251 var state = this.getActiveTabState();
1252 state.groups[groupId] = tabIndex;
1254 window.sessionStorage.setItem('tab', JSON.stringify(state));
1256 catch (e) { return false; }
1261 updateTabs: function(ev) {
1262 document.querySelectorAll('[data-tab-title]').forEach(function(pane) {
1263 var menu = pane.parentNode.previousElementSibling,
1264 tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))),
1265 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
1267 if (!pane.firstElementChild) {
1268 tab.style.display = 'none';
1269 tab.classList.remove('flash');
1271 else if (tab.style.display === 'none') {
1272 tab.style.display = '';
1273 requestAnimationFrame(function() { tab.classList.add('flash') });
1277 tab.setAttribute('data-errors', n_errors);
1278 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
1279 tab.setAttribute('data-tooltip-style', 'error');
1282 tab.removeAttribute('data-errors');
1283 tab.removeAttribute('data-tooltip');
1288 switchTab: function(ev) {
1289 var tab = ev.target.parentNode,
1290 name = tab.getAttribute('data-tab'),
1291 menu = tab.parentNode,
1292 group = menu.nextElementSibling,
1293 groupId = +group.getAttribute('data-tab-group'),
1296 ev.preventDefault();
1298 if (!tab.classList.contains('cbi-tab-disabled'))
1301 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
1302 tab.classList.remove('cbi-tab');
1303 tab.classList.remove('cbi-tab-disabled');
1305 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
1308 group.childNodes.forEach(function(pane) {
1309 if (L.dom.matches(pane, '[data-tab]')) {
1310 if (pane.getAttribute('data-tab') === name) {
1311 pane.setAttribute('data-tab-active', 'true');
1312 L.ui.tabs.setActiveTabId(groupId, index);
1315 pane.setAttribute('data-tab-active', 'false');
1325 changes: L.Class.singleton({
1327 if (!L.env.sessionid)
1330 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
1333 setIndicator: function(n) {
1334 var i = document.querySelector('.uci_change_indicator');
1336 var poll = document.getElementById('xhr_poll_status');
1337 i = poll.parentNode.insertBefore(E('a', {
1339 'class': 'uci_change_indicator label notice',
1340 'click': L.bind(this.displayChanges, this)
1345 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
1346 i.classList.add('flash');
1347 i.style.display = '';
1350 i.classList.remove('flash');
1351 i.style.display = 'none';
1355 renderChangeIndicator: function(changes) {
1358 for (var config in changes)
1359 if (changes.hasOwnProperty(config))
1360 n_changes += changes[config].length;
1362 this.changes = changes;
1363 this.setIndicator(n_changes);
1367 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
1368 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
1369 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
1370 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
1371 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
1372 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
1373 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
1374 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
1375 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
1376 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
1379 displayChanges: function() {
1380 var list = E('div', { 'class': 'uci-change-list' }),
1381 dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
1382 E('div', { 'class': 'cbi-section' }, [
1383 E('strong', _('Legend:')),
1384 E('div', { 'class': 'uci-change-legend' }, [
1385 E('div', { 'class': 'uci-change-legend-label' }, [
1386 E('ins', ' '), ' ', _('Section added') ]),
1387 E('div', { 'class': 'uci-change-legend-label' }, [
1388 E('del', ' '), ' ', _('Section removed') ]),
1389 E('div', { 'class': 'uci-change-legend-label' }, [
1390 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
1391 E('div', { 'class': 'uci-change-legend-label' }, [
1392 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
1394 E('div', { 'class': 'right' }, [
1398 'click': L.ui.hideModal,
1399 'value': _('Dismiss')
1403 'class': 'cbi-button cbi-button-positive important',
1404 'click': L.bind(this.apply, this, true),
1405 'value': _('Save & Apply')
1409 'class': 'cbi-button cbi-button-reset',
1410 'click': L.bind(this.revert, this),
1411 'value': _('Revert')
1415 for (var config in this.changes) {
1416 if (!this.changes.hasOwnProperty(config))
1419 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
1421 for (var i = 0, added = null; i < this.changes[config].length; i++) {
1422 var chg = this.changes[config][i],
1423 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
1425 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
1431 if (added != null && chg[1] == added[0])
1432 return '@' + added[1] + '[-1]';
1437 return "'" + chg[3].replace(/'/g, "'\"'\"'") + "'";
1444 if (chg[0] == 'add')
1445 added = [ chg[1], chg[2] ];
1449 list.appendChild(E('br'));
1450 dlg.classList.add('uci-dialog');
1453 displayStatus: function(type, content) {
1455 var message = L.ui.showModal('', '');
1457 message.classList.add('alert-message');
1458 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
1461 L.dom.content(message, content);
1463 if (!this.was_polling) {
1464 this.was_polling = L.Request.poll.active();
1465 L.Request.poll.stop();
1471 if (this.was_polling)
1472 L.Request.poll.start();
1476 rollback: function(checked) {
1478 this.displayStatus('warning spinning',
1479 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
1480 .format(L.env.apply_rollback)));
1482 var call = function(r, data, duration) {
1483 if (r.status === 204) {
1484 L.ui.changes.displayStatus('warning', [
1485 E('h4', _('Configuration has been rolled back!')),
1486 E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)),
1487 E('div', { 'class': 'right' }, [
1491 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false),
1492 'value': _('Dismiss')
1496 'class': 'btn cbi-button-action important',
1497 'click': L.bind(L.ui.changes.revert, L.ui.changes),
1498 'value': _('Revert changes')
1502 'class': 'btn cbi-button-negative important',
1503 'click': L.bind(L.ui.changes.apply, L.ui.changes, false),
1504 'value': _('Apply unchecked')
1512 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
1513 window.setTimeout(function() {
1514 L.Request.request(L.url('admin/uci/confirm'), {
1516 timeout: L.env.apply_timeout * 1000,
1517 query: { sid: L.env.sessionid, token: L.env.token }
1522 call({ status: 0 });
1525 this.displayStatus('warning', [
1526 E('h4', _('Device unreachable!')),
1527 E('p', _('Could not regain access to the device after applying the configuration changes. You might need to reconnect if you modified network related settings such as the IP address or wireless security credentials.'))
1532 confirm: function(checked, deadline, override_token) {
1534 var ts = Date.now();
1536 this.displayStatus('notice');
1539 this.confirm_auth = { token: override_token };
1541 var call = function(r, data, duration) {
1542 if (Date.now() >= deadline) {
1543 window.clearTimeout(tt);
1544 L.ui.changes.rollback(checked);
1547 else if (r && (r.status === 200 || r.status === 204)) {
1548 document.dispatchEvent(new CustomEvent('uci-applied'));
1550 L.ui.changes.setIndicator(0);
1551 L.ui.changes.displayStatus('notice',
1552 E('p', _('Configuration has been applied.')));
1554 window.clearTimeout(tt);
1555 window.setTimeout(function() {
1556 //L.ui.changes.displayStatus(false);
1557 window.location = window.location.href.split('#')[0];
1558 }, L.env.apply_display * 1000);
1563 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
1564 window.setTimeout(function() {
1565 L.Request.request(L.url('admin/uci/confirm'), {
1567 timeout: L.env.apply_timeout * 1000,
1568 query: L.ui.changes.confirm_auth
1573 var tick = function() {
1574 var now = Date.now();
1576 L.ui.changes.displayStatus('notice spinning',
1577 E('p', _('Waiting for configuration to get applied… %ds')
1578 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
1580 if (now >= deadline)
1583 tt = window.setTimeout(tick, 1000 - (now - ts));
1589 /* wait a few seconds for the settings to become effective */
1590 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
1593 apply: function(checked) {
1594 this.displayStatus('notice spinning',
1595 E('p', _('Starting configuration apply…')));
1597 L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
1599 query: { sid: L.env.sessionid, token: L.env.token }
1600 }).then(function(r) {
1601 if (r.status === (checked ? 200 : 204)) {
1602 var tok = null; try { tok = r.json(); } catch(e) {}
1603 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
1604 L.ui.changes.confirm_auth = tok;
1606 L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
1608 else if (checked && r.status === 204) {
1609 L.ui.changes.displayStatus('notice',
1610 E('p', _('There are no changes to apply')));
1612 window.setTimeout(function() {
1613 L.ui.changes.displayStatus(false);
1614 }, L.env.apply_display * 1000);
1617 L.ui.changes.displayStatus('warning',
1618 E('p', _('Apply request failed with status <code>%h</code>%>')
1619 .format(r.responseText || r.statusText || r.status)));
1621 window.setTimeout(function() {
1622 L.ui.changes.displayStatus(false);
1623 }, L.env.apply_display * 1000);
1628 revert: function() {
1629 this.displayStatus('notice spinning',
1630 E('p', _('Reverting configuration…')));
1632 L.Request.request(L.url('admin/uci/revert'), {
1634 query: { sid: L.env.sessionid, token: L.env.token }
1635 }).then(function(r) {
1636 if (r.status === 200) {
1637 document.dispatchEvent(new CustomEvent('uci-reverted'));
1639 L.ui.changes.setIndicator(0);
1640 L.ui.changes.displayStatus('notice',
1641 E('p', _('Changes have been reverted.')));
1643 window.setTimeout(function() {
1644 //L.ui.changes.displayStatus(false);
1645 window.location = window.location.href.split('#')[0];
1646 }, L.env.apply_display * 1000);
1649 L.ui.changes.displayStatus('warning',
1650 E('p', _('Revert request failed with status <code>%h</code>')
1651 .format(r.statusText || r.status)));
1653 window.setTimeout(function() {
1654 L.ui.changes.displayStatus(false);
1655 }, L.env.apply_display * 1000);
1661 addValidator: function(field, type, optional /*, ... */) {
1665 var events = this.varargs(arguments, 3);
1666 if (events.length == 0)
1667 events.push('blur', 'keyup');
1670 var cbiValidator = new CBIValidator(field, type, optional),
1671 validatorFn = cbiValidator.validate.bind(cbiValidator);
1673 for (var i = 0; i < events.length; i++)
1674 field.addEventListener(events[i], validatorFn);
1682 Dropdown: UIDropdown,
1683 DynamicList: UIDynamicList,
1684 Combobox: UICombobox