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