luci-base: ui.js: HTML escape uci changelog values
[oweals/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2 'require uci';
3 'require validation';
4
5 var modalDiv = null,
6     tooltipDiv = null,
7     tooltipTimeout = null;
8
9 var UIElement = L.Class.extend({
10         getValue: function() {
11                 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
12                         return this.node.value;
13
14                 return null;
15         },
16
17         setValue: function(value) {
18                 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
19                         this.node.value = value;
20         },
21
22         isValid: function() {
23                 return (this.validState !== false);
24         },
25
26         triggerValidation: function() {
27                 if (typeof(this.vfunc) != 'function')
28                         return false;
29
30                 var wasValid = this.isValid();
31
32                 this.vfunc();
33
34                 return (wasValid != this.isValid());
35         },
36
37         registerEvents: function(targetNode, synevent, events) {
38                 var dispatchFn = L.bind(function(ev) {
39                         this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
40                 }, this);
41
42                 for (var i = 0; i < events.length; i++)
43                         targetNode.addEventListener(events[i], dispatchFn);
44         },
45
46         setUpdateEvents: function(targetNode /*, ... */) {
47                 var datatype = this.options.datatype,
48                     optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
49                     validate = this.options.validate,
50                     events = this.varargs(arguments, 1);
51
52                 this.registerEvents(targetNode, 'widget-update', events);
53
54                 if (!datatype && !validate)
55                         return;
56
57                 this.vfunc = L.ui.addValidator.apply(L.ui, [
58                         targetNode, datatype || 'string',
59                         optional, validate
60                 ].concat(events));
61
62                 this.node.addEventListener('validation-success', L.bind(function(ev) {
63                         this.validState = true;
64                 }, this));
65
66                 this.node.addEventListener('validation-failure', L.bind(function(ev) {
67                         this.validState = false;
68                 }, this));
69         },
70
71         setChangeEvents: function(targetNode /*, ... */) {
72                 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
73         }
74 });
75
76 var UITextfield = UIElement.extend({
77         __init__: function(value, options) {
78                 this.value = value;
79                 this.options = Object.assign({
80                         optional: true,
81                         password: false
82                 }, options);
83         },
84
85         render: function() {
86                 var frameEl = E('div', { 'id': this.options.id });
87
88                 if (this.options.password) {
89                         frameEl.classList.add('nowrap');
90                         frameEl.appendChild(E('input', {
91                                 'type': 'password',
92                                 'style': 'position:absolute; left:-100000px',
93                                 'aria-hidden': true,
94                                 'tabindex': -1,
95                                 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
96                         }));
97                 }
98
99                 frameEl.appendChild(E('input', {
100                         'id': this.options.id ? 'widget.' + this.options.id : null,
101                         'name': this.options.name,
102                         'type': this.options.password ? 'password' : 'text',
103                         'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
104                         'readonly': this.options.readonly ? '' : null,
105                         'maxlength': this.options.maxlength,
106                         'placeholder': this.options.placeholder,
107                         'value': this.value,
108                 }));
109
110                 if (this.options.password)
111                         frameEl.appendChild(E('button', {
112                                 'class': 'cbi-button cbi-button-neutral',
113                                 'title': _('Reveal/hide password'),
114                                 'aria-label': _('Reveal/hide password'),
115                                 'click': function(ev) {
116                                         var e = this.previousElementSibling;
117                                         e.type = (e.type === 'password') ? 'text' : 'password';
118                                         ev.preventDefault();
119                                 }
120                         }, '∗'));
121
122                 return this.bind(frameEl);
123         },
124
125         bind: function(frameEl) {
126                 var inputEl = frameEl.childNodes[+!!this.options.password];
127
128                 this.node = frameEl;
129
130                 this.setUpdateEvents(inputEl, 'keyup', 'blur');
131                 this.setChangeEvents(inputEl, 'change');
132
133                 L.dom.bindClassInstance(frameEl, this);
134
135                 return frameEl;
136         },
137
138         getValue: function() {
139                 var inputEl = this.node.childNodes[+!!this.options.password];
140                 return inputEl.value;
141         },
142
143         setValue: function(value) {
144                 var inputEl = this.node.childNodes[+!!this.options.password];
145                 inputEl.value = value;
146         }
147 });
148
149 var UICheckbox = UIElement.extend({
150         __init__: function(value, options) {
151                 this.value = value;
152                 this.options = Object.assign({
153                         value_enabled: '1',
154                         value_disabled: '0'
155                 }, options);
156         },
157
158         render: function() {
159                 var frameEl = E('div', {
160                         'id': this.options.id,
161                         'class': 'cbi-checkbox'
162                 });
163
164                 if (this.options.hiddenname)
165                         frameEl.appendChild(E('input', {
166                                 'type': 'hidden',
167                                 'name': this.options.hiddenname,
168                                 'value': 1
169                         }));
170
171                 frameEl.appendChild(E('input', {
172                         'id': this.options.id ? 'widget.' + this.options.id : null,
173                         'name': this.options.name,
174                         'type': 'checkbox',
175                         'value': this.options.value_enabled,
176                         'checked': (this.value == this.options.value_enabled) ? '' : null
177                 }));
178
179                 return this.bind(frameEl);
180         },
181
182         bind: function(frameEl) {
183                 this.node = frameEl;
184
185                 this.setUpdateEvents(frameEl.lastElementChild, 'click', 'blur');
186                 this.setChangeEvents(frameEl.lastElementChild, 'change');
187
188                 L.dom.bindClassInstance(frameEl, this);
189
190                 return frameEl;
191         },
192
193         isChecked: function() {
194                 return this.node.lastElementChild.checked;
195         },
196
197         getValue: function() {
198                 return this.isChecked()
199                         ? this.options.value_enabled
200                         : this.options.value_disabled;
201         },
202
203         setValue: function(value) {
204                 this.node.lastElementChild.checked = (value == this.options.value_enabled);
205         }
206 });
207
208 var UISelect = UIElement.extend({
209         __init__: function(value, choices, options) {
210                 if (typeof(choices) != 'object')
211                         choices = {};
212
213                 if (!Array.isArray(value))
214                         value = (value != null && value != '') ? [ value ] : [];
215
216                 if (!options.multiple && value.length > 1)
217                         value.length = 1;
218
219                 this.values = value;
220                 this.choices = choices;
221                 this.options = Object.assign({
222                         multiple: false,
223                         widget: 'select',
224                         orientation: 'horizontal'
225                 }, options);
226         },
227
228         render: function() {
229                 var frameEl = E('div', { 'id': this.options.id }),
230                     keys = Object.keys(this.choices);
231
232                 if (this.options.sort === true)
233                         keys.sort();
234                 else if (Array.isArray(this.options.sort))
235                         keys = this.options.sort;
236
237                 if (this.options.widget == 'select') {
238                         frameEl.appendChild(E('select', {
239                                 'id': this.options.id ? 'widget.' + this.options.id : null,
240                                 'name': this.options.name,
241                                 'size': this.options.size,
242                                 'class': 'cbi-input-select',
243                                 'multiple': this.options.multiple ? '' : null
244                         }));
245
246                         if (this.options.optional)
247                                 frameEl.lastChild.appendChild(E('option', {
248                                         'value': '',
249                                         'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
250                                 }, this.choices[''] || this.options.placeholder || _('-- Please choose --')));
251
252                         for (var i = 0; i < keys.length; i++) {
253                                 if (keys[i] == null || keys[i] == '')
254                                         continue;
255
256                                 frameEl.lastChild.appendChild(E('option', {
257                                         'value': keys[i],
258                                         'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
259                                 }, this.choices[keys[i]] || keys[i]));
260                         }
261                 }
262                 else {
263                         var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
264
265                         for (var i = 0; i < keys.length; i++) {
266                                 frameEl.appendChild(E('label', {}, [
267                                         E('input', {
268                                                 'id': this.options.id ? 'widget.' + this.options.id : null,
269                                                 'name': this.options.id || this.options.name,
270                                                 'type': this.options.multiple ? 'checkbox' : 'radio',
271                                                 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
272                                                 'value': keys[i],
273                                                 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null
274                                         }),
275                                         this.choices[keys[i]] || keys[i]
276                                 ]));
277
278                                 if (i + 1 == this.options.size)
279                                         frameEl.appendChild(brEl);
280                         }
281                 }
282
283                 return this.bind(frameEl);
284         },
285
286         bind: function(frameEl) {
287                 this.node = frameEl;
288
289                 if (this.options.widget == 'select') {
290                         this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
291                         this.setChangeEvents(frameEl.firstChild, 'change');
292                 }
293                 else {
294                         var radioEls = frameEl.querySelectorAll('input[type="radio"]');
295                         for (var i = 0; i < radioEls.length; i++) {
296                                 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
297                                 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
298                         }
299                 }
300
301                 L.dom.bindClassInstance(frameEl, this);
302
303                 return frameEl;
304         },
305
306         getValue: function() {
307                 if (this.options.widget == 'select')
308                         return this.node.firstChild.value;
309
310                 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
311                 for (var i = 0; i < radioEls.length; i++)
312                         if (radioEls[i].checked)
313                                 return radioEls[i].value;
314
315                 return null;
316         },
317
318         setValue: function(value) {
319                 if (this.options.widget == 'select') {
320                         if (value == null)
321                                 value = '';
322
323                         for (var i = 0; i < this.node.firstChild.options.length; i++)
324                                 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
325
326                         return;
327                 }
328
329                 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
330                 for (var i = 0; i < radioEls.length; i++)
331                         radioEls[i].checked = (radioEls[i].value == value);
332         }
333 });
334
335 var UIDropdown = UIElement.extend({
336         __init__: function(value, choices, options) {
337                 if (typeof(choices) != 'object')
338                         choices = {};
339
340                 if (!Array.isArray(value))
341                         this.values = (value != null && value != '') ? [ value ] : [];
342                 else
343                         this.values = value;
344
345                 this.choices = choices;
346                 this.options = Object.assign({
347                         sort:               true,
348                         multiple:           Array.isArray(value),
349                         optional:           true,
350                         select_placeholder: _('-- Please choose --'),
351                         custom_placeholder: _('-- custom --'),
352                         display_items:      3,
353                         dropdown_items:     -1,
354                         create:             false,
355                         create_query:       '.create-item-input',
356                         create_template:    'script[type="item-template"]'
357                 }, options);
358         },
359
360         render: function() {
361                 var sb = E('div', {
362                         'id': this.options.id,
363                         'class': 'cbi-dropdown',
364                         'multiple': this.options.multiple ? '' : null,
365                         'optional': this.options.optional ? '' : null,
366                 }, E('ul'));
367
368                 var keys = Object.keys(this.choices);
369
370                 if (this.options.sort === true)
371                         keys.sort();
372                 else if (Array.isArray(this.options.sort))
373                         keys = this.options.sort;
374
375                 if (this.options.create)
376                         for (var i = 0; i < this.values.length; i++)
377                                 if (!this.choices.hasOwnProperty(this.values[i]))
378                                         keys.push(this.values[i]);
379
380                 for (var i = 0; i < keys.length; i++)
381                         sb.lastElementChild.appendChild(E('li', {
382                                 'data-value': keys[i],
383                                 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
384                         }, this.choices[keys[i]] || keys[i]));
385
386                 if (this.options.create) {
387                         var createEl = E('input', {
388                                 'type': 'text',
389                                 'class': 'create-item-input',
390                                 'readonly': this.options.readonly ? '' : null,
391                                 'maxlength': this.options.maxlength,
392                                 'placeholder': this.options.custom_placeholder || this.options.placeholder
393                         });
394
395                         if (this.options.datatype)
396                                 L.ui.addValidator(createEl, this.options.datatype,
397                                                   true, null, 'blur', 'keyup');
398
399                         sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
400                 }
401
402                 if (this.options.create_markup)
403                         sb.appendChild(E('script', { type: 'item-template' },
404                                 this.options.create_markup));
405
406                 return this.bind(sb);
407         },
408
409         bind: function(sb) {
410                 var o = this.options;
411
412                 o.multiple = sb.hasAttribute('multiple');
413                 o.optional = sb.hasAttribute('optional');
414                 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
415                 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
416                 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
417                 o.create_query = sb.getAttribute('item-create') || o.create_query;
418                 o.create_template = sb.getAttribute('item-template') || o.create_template;
419
420                 var ul = sb.querySelector('ul'),
421                     more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
422                     open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
423                     canary = sb.appendChild(E('div')),
424                     create = sb.querySelector(this.options.create_query),
425                     ndisplay = this.options.display_items,
426                     n = 0;
427
428                 if (this.options.multiple) {
429                         var items = ul.querySelectorAll('li');
430
431                         for (var i = 0; i < items.length; i++) {
432                                 this.transformItem(sb, items[i]);
433
434                                 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
435                                         items[i].setAttribute('display', n++);
436                         }
437                 }
438                 else {
439                         if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
440                                 var placeholder = E('li', { placeholder: '' },
441                                         this.options.select_placeholder || this.options.placeholder);
442
443                                 ul.firstChild
444                                         ? ul.insertBefore(placeholder, ul.firstChild)
445                                         : ul.appendChild(placeholder);
446                         }
447
448                         var items = ul.querySelectorAll('li'),
449                             sel = sb.querySelectorAll('[selected]');
450
451                         sel.forEach(function(s) {
452                                 s.removeAttribute('selected');
453                         });
454
455                         var s = sel[0] || items[0];
456                         if (s) {
457                                 s.setAttribute('selected', '');
458                                 s.setAttribute('display', n++);
459                         }
460
461                         ndisplay--;
462                 }
463
464                 this.saveValues(sb, ul);
465
466                 ul.setAttribute('tabindex', -1);
467                 sb.setAttribute('tabindex', 0);
468
469                 if (ndisplay < 0)
470                         sb.setAttribute('more', '')
471                 else
472                         sb.removeAttribute('more');
473
474                 if (ndisplay == this.options.display_items)
475                         sb.setAttribute('empty', '')
476                 else
477                         sb.removeAttribute('empty');
478
479                 L.dom.content(more, (ndisplay == this.options.display_items)
480                         ? (this.options.select_placeholder || this.options.placeholder) : '···');
481
482
483                 sb.addEventListener('click', this.handleClick.bind(this));
484                 sb.addEventListener('keydown', this.handleKeydown.bind(this));
485                 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
486                 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
487
488                 if ('ontouchstart' in window) {
489                         sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
490                         window.addEventListener('touchstart', this.closeAllDropdowns);
491                 }
492                 else {
493                         sb.addEventListener('mouseover', this.handleMouseover.bind(this));
494                         sb.addEventListener('focus', this.handleFocus.bind(this));
495
496                         canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
497
498                         window.addEventListener('mouseover', this.setFocus);
499                         window.addEventListener('click', this.closeAllDropdowns);
500                 }
501
502                 if (create) {
503                         create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
504                         create.addEventListener('focus', this.handleCreateFocus.bind(this));
505                         create.addEventListener('blur', this.handleCreateBlur.bind(this));
506
507                         var li = findParent(create, 'li');
508
509                         li.setAttribute('unselectable', '');
510                         li.addEventListener('click', this.handleCreateClick.bind(this));
511                 }
512
513                 this.node = sb;
514
515                 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
516                 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
517
518                 L.dom.bindClassInstance(sb, this);
519
520                 return sb;
521         },
522
523         openDropdown: function(sb) {
524                 var st = window.getComputedStyle(sb, null),
525                     ul = sb.querySelector('ul'),
526                     li = ul.querySelectorAll('li'),
527                     fl = findParent(sb, '.cbi-value-field'),
528                     sel = ul.querySelector('[selected]'),
529                     rect = sb.getBoundingClientRect(),
530                     items = Math.min(this.options.dropdown_items, li.length);
531
532                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
533                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
534                 });
535
536                 sb.setAttribute('open', '');
537
538                 var pv = ul.cloneNode(true);
539                     pv.classList.add('preview');
540
541                 if (fl)
542                         fl.classList.add('cbi-dropdown-open');
543
544                 if ('ontouchstart' in window) {
545                         var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
546                             vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
547                             scrollFrom = window.pageYOffset,
548                             scrollTo = scrollFrom + rect.top - vpHeight * 0.5,
549                             start = null;
550
551                         ul.style.top = sb.offsetHeight + 'px';
552                         ul.style.left = -rect.left + 'px';
553                         ul.style.right = (rect.right - vpWidth) + 'px';
554                         ul.style.maxHeight = (vpHeight * 0.5) + 'px';
555                         ul.style.WebkitOverflowScrolling = 'touch';
556
557                         var scrollStep = function(timestamp) {
558                                 if (!start) {
559                                         start = timestamp;
560                                         ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
561                                 }
562
563                                 var duration = Math.max(timestamp - start, 1);
564                                 if (duration < 100) {
565                                         document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
566                                         window.requestAnimationFrame(scrollStep);
567                                 }
568                                 else {
569                                         document.body.scrollTop = scrollTo;
570                                 }
571                         };
572
573                         window.requestAnimationFrame(scrollStep);
574                 }
575                 else {
576                         ul.style.maxHeight = '1px';
577                         ul.style.top = ul.style.bottom = '';
578
579                         window.requestAnimationFrame(function() {
580                                 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
581                                     fullHeight = 0,
582                                     spaceAbove = rect.top,
583                                     spaceBelow = window.innerHeight - rect.height - rect.top;
584
585                                 for (var i = 0; i < (items == -1 ? li.length : items); i++)
586                                         fullHeight += li[i].getBoundingClientRect().height;
587
588                                 if (fullHeight <= spaceBelow) {
589                                         ul.style.top = rect.height + 'px';
590                                         ul.style.maxHeight = spaceBelow + 'px';
591                                 }
592                                 else if (fullHeight <= spaceAbove) {
593                                         ul.style.bottom = rect.height + 'px';
594                                         ul.style.maxHeight = spaceAbove + 'px';
595                                 }
596                                 else if (spaceBelow >= spaceAbove) {
597                                         ul.style.top = rect.height + 'px';
598                                         ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
599                                 }
600                                 else {
601                                         ul.style.bottom = rect.height + 'px';
602                                         ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
603                                 }
604
605                                 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
606                         });
607                 }
608
609                 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
610                 for (var i = 0; i < cboxes.length; i++) {
611                         cboxes[i].checked = true;
612                         cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
613                 };
614
615                 ul.classList.add('dropdown');
616
617                 sb.insertBefore(pv, ul.nextElementSibling);
618
619                 li.forEach(function(l) {
620                         l.setAttribute('tabindex', 0);
621                 });
622
623                 sb.lastElementChild.setAttribute('tabindex', 0);
624
625                 this.setFocus(sb, sel || li[0], true);
626         },
627
628         closeDropdown: function(sb, no_focus) {
629                 if (!sb.hasAttribute('open'))
630                         return;
631
632                 var pv = sb.querySelector('ul.preview'),
633                     ul = sb.querySelector('ul.dropdown'),
634                     li = ul.querySelectorAll('li'),
635                     fl = findParent(sb, '.cbi-value-field');
636
637                 li.forEach(function(l) { l.removeAttribute('tabindex'); });
638                 sb.lastElementChild.removeAttribute('tabindex');
639
640                 sb.removeChild(pv);
641                 sb.removeAttribute('open');
642                 sb.style.width = sb.style.height = '';
643
644                 ul.classList.remove('dropdown');
645                 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
646
647                 if (fl)
648                         fl.classList.remove('cbi-dropdown-open');
649
650                 if (!no_focus)
651                         this.setFocus(sb, sb);
652
653                 this.saveValues(sb, ul);
654         },
655
656         toggleItem: function(sb, li, force_state) {
657                 if (li.hasAttribute('unselectable'))
658                         return;
659
660                 if (this.options.multiple) {
661                         var cbox = li.querySelector('input[type="checkbox"]'),
662                             items = li.parentNode.querySelectorAll('li'),
663                             label = sb.querySelector('ul.preview'),
664                             sel = li.parentNode.querySelectorAll('[selected]').length,
665                             more = sb.querySelector('.more'),
666                             ndisplay = this.options.display_items,
667                             n = 0;
668
669                         if (li.hasAttribute('selected')) {
670                                 if (force_state !== true) {
671                                         if (sel > 1 || this.options.optional) {
672                                                 li.removeAttribute('selected');
673                                                 cbox.checked = cbox.disabled = false;
674                                                 sel--;
675                                         }
676                                         else {
677                                                 cbox.disabled = true;
678                                         }
679                                 }
680                         }
681                         else {
682                                 if (force_state !== false) {
683                                         li.setAttribute('selected', '');
684                                         cbox.checked = true;
685                                         cbox.disabled = false;
686                                         sel++;
687                                 }
688                         }
689
690                         while (label && label.firstElementChild)
691                                 label.removeChild(label.firstElementChild);
692
693                         for (var i = 0; i < items.length; i++) {
694                                 items[i].removeAttribute('display');
695                                 if (items[i].hasAttribute('selected')) {
696                                         if (ndisplay-- > 0) {
697                                                 items[i].setAttribute('display', n++);
698                                                 if (label)
699                                                         label.appendChild(items[i].cloneNode(true));
700                                         }
701                                         var c = items[i].querySelector('input[type="checkbox"]');
702                                         if (c)
703                                                 c.disabled = (sel == 1 && !this.options.optional);
704                                 }
705                         }
706
707                         if (ndisplay < 0)
708                                 sb.setAttribute('more', '');
709                         else
710                                 sb.removeAttribute('more');
711
712                         if (ndisplay === this.options.display_items)
713                                 sb.setAttribute('empty', '');
714                         else
715                                 sb.removeAttribute('empty');
716
717                         L.dom.content(more, (ndisplay === this.options.display_items)
718                                 ? (this.options.select_placeholder || this.options.placeholder) : '···');
719                 }
720                 else {
721                         var sel = li.parentNode.querySelector('[selected]');
722                         if (sel) {
723                                 sel.removeAttribute('display');
724                                 sel.removeAttribute('selected');
725                         }
726
727                         li.setAttribute('display', 0);
728                         li.setAttribute('selected', '');
729
730                         this.closeDropdown(sb, true);
731                 }
732
733                 this.saveValues(sb, li.parentNode);
734         },
735
736         transformItem: function(sb, li) {
737                 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
738                     label = E('label');
739
740                 while (li.firstChild)
741                         label.appendChild(li.firstChild);
742
743                 li.appendChild(cbox);
744                 li.appendChild(label);
745         },
746
747         saveValues: function(sb, ul) {
748                 var sel = ul.querySelectorAll('li[selected]'),
749                     div = sb.lastElementChild,
750                     name = this.options.name,
751                     strval = '',
752                     values = [];
753
754                 while (div.lastElementChild)
755                         div.removeChild(div.lastElementChild);
756
757                 sel.forEach(function (s) {
758                         if (s.hasAttribute('placeholder'))
759                                 return;
760
761                         var v = {
762                                 text: s.innerText,
763                                 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
764                                 element: s
765                         };
766
767                         div.appendChild(E('input', {
768                                 type: 'hidden',
769                                 name: name,
770                                 value: v.value
771                         }));
772
773                         values.push(v);
774
775                         strval += strval.length ? ' ' + v.value : v.value;
776                 });
777
778                 var detail = {
779                         instance: this,
780                         element: sb
781                 };
782
783                 if (this.options.multiple)
784                         detail.values = values;
785                 else
786                         detail.value = values.length ? values[0] : null;
787
788                 sb.value = strval;
789
790                 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
791                         bubbles: true,
792                         detail: detail
793                 }));
794         },
795
796         setValues: function(sb, values) {
797                 var ul = sb.querySelector('ul');
798
799                 if (this.options.create) {
800                         for (var value in values) {
801                                 this.createItems(sb, value);
802
803                                 if (!this.options.multiple)
804                                         break;
805                         }
806                 }
807
808                 if (this.options.multiple) {
809                         var lis = ul.querySelectorAll('li[data-value]');
810                         for (var i = 0; i < lis.length; i++) {
811                                 var value = lis[i].getAttribute('data-value');
812                                 if (values === null || !(value in values))
813                                         this.toggleItem(sb, lis[i], false);
814                                 else
815                                         this.toggleItem(sb, lis[i], true);
816                         }
817                 }
818                 else {
819                         var ph = ul.querySelector('li[placeholder]');
820                         if (ph)
821                                 this.toggleItem(sb, ph);
822
823                         var lis = ul.querySelectorAll('li[data-value]');
824                         for (var i = 0; i < lis.length; i++) {
825                                 var value = lis[i].getAttribute('data-value');
826                                 if (values !== null && (value in values))
827                                         this.toggleItem(sb, lis[i]);
828                         }
829                 }
830         },
831
832         setFocus: function(sb, elem, scroll) {
833                 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
834                         return;
835
836                 if (sb.target && findParent(sb.target, 'ul.dropdown'))
837                         return;
838
839                 document.querySelectorAll('.focus').forEach(function(e) {
840                         if (!matchesElem(e, 'input')) {
841                                 e.classList.remove('focus');
842                                 e.blur();
843                         }
844                 });
845
846                 if (elem) {
847                         elem.focus();
848                         elem.classList.add('focus');
849
850                         if (scroll)
851                                 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
852                 }
853         },
854
855         createItems: function(sb, value) {
856                 var sbox = this,
857                     val = (value || '').trim(),
858                     ul = sb.querySelector('ul');
859
860                 if (!sbox.options.multiple)
861                         val = val.length ? [ val ] : [];
862                 else
863                         val = val.length ? val.split(/\s+/) : [];
864
865                 val.forEach(function(item) {
866                         var new_item = null;
867
868                         ul.childNodes.forEach(function(li) {
869                                 if (li.getAttribute && li.getAttribute('data-value') === item)
870                                         new_item = li;
871                         });
872
873                         if (!new_item) {
874                                 var markup,
875                                     tpl = sb.querySelector(sbox.options.create_template);
876
877                                 if (tpl)
878                                         markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
879                                 else
880                                         markup = '<li data-value="{{value}}">{{value}}</li>';
881
882                                 new_item = E(markup.replace(/{{value}}/g, '%h'.format(item)));
883
884                                 if (sbox.options.multiple) {
885                                         sbox.transformItem(sb, new_item);
886                                 }
887                                 else {
888                                         var old = ul.querySelector('li[created]');
889                                         if (old)
890                                                 ul.removeChild(old);
891
892                                         new_item.setAttribute('created', '');
893                                 }
894
895                                 new_item = ul.insertBefore(new_item, ul.lastElementChild);
896                         }
897
898                         sbox.toggleItem(sb, new_item, true);
899                         sbox.setFocus(sb, new_item, true);
900                 });
901         },
902
903         closeAllDropdowns: function() {
904                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
905                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
906                 });
907         },
908
909         handleClick: function(ev) {
910                 var sb = ev.currentTarget;
911
912                 if (!sb.hasAttribute('open')) {
913                         if (!matchesElem(ev.target, 'input'))
914                                 this.openDropdown(sb);
915                 }
916                 else {
917                         var li = findParent(ev.target, 'li');
918                         if (li && li.parentNode.classList.contains('dropdown'))
919                                 this.toggleItem(sb, li);
920                         else if (li && li.parentNode.classList.contains('preview'))
921                                 this.closeDropdown(sb);
922                         else if (matchesElem(ev.target, 'span.open, span.more'))
923                                 this.closeDropdown(sb);
924                 }
925
926                 ev.preventDefault();
927                 ev.stopPropagation();
928         },
929
930         handleKeydown: function(ev) {
931                 var sb = ev.currentTarget;
932
933                 if (matchesElem(ev.target, 'input'))
934                         return;
935
936                 if (!sb.hasAttribute('open')) {
937                         switch (ev.keyCode) {
938                         case 37:
939                         case 38:
940                         case 39:
941                         case 40:
942                                 this.openDropdown(sb);
943                                 ev.preventDefault();
944                         }
945                 }
946                 else {
947                         var active = findParent(document.activeElement, 'li');
948
949                         switch (ev.keyCode) {
950                         case 27:
951                                 this.closeDropdown(sb);
952                                 break;
953
954                         case 13:
955                                 if (active) {
956                                         if (!active.hasAttribute('selected'))
957                                                 this.toggleItem(sb, active);
958                                         this.closeDropdown(sb);
959                                         ev.preventDefault();
960                                 }
961                                 break;
962
963                         case 32:
964                                 if (active) {
965                                         this.toggleItem(sb, active);
966                                         ev.preventDefault();
967                                 }
968                                 break;
969
970                         case 38:
971                                 if (active && active.previousElementSibling) {
972                                         this.setFocus(sb, active.previousElementSibling);
973                                         ev.preventDefault();
974                                 }
975                                 break;
976
977                         case 40:
978                                 if (active && active.nextElementSibling) {
979                                         this.setFocus(sb, active.nextElementSibling);
980                                         ev.preventDefault();
981                                 }
982                                 break;
983                         }
984                 }
985         },
986
987         handleDropdownClose: function(ev) {
988                 var sb = ev.currentTarget;
989
990                 this.closeDropdown(sb, true);
991         },
992
993         handleDropdownSelect: function(ev) {
994                 var sb = ev.currentTarget,
995                     li = findParent(ev.target, 'li');
996
997                 if (!li)
998                         return;
999
1000                 this.toggleItem(sb, li);
1001                 this.closeDropdown(sb, true);
1002         },
1003
1004         handleMouseover: function(ev) {
1005                 var sb = ev.currentTarget;
1006
1007                 if (!sb.hasAttribute('open'))
1008                         return;
1009
1010                 var li = findParent(ev.target, 'li');
1011
1012                 if (li && li.parentNode.classList.contains('dropdown'))
1013                         this.setFocus(sb, li);
1014         },
1015
1016         handleFocus: function(ev) {
1017                 var sb = ev.currentTarget;
1018
1019                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1020                         if (s !== sb || sb.hasAttribute('open'))
1021                                 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1022                 });
1023         },
1024
1025         handleCanaryFocus: function(ev) {
1026                 this.closeDropdown(ev.currentTarget.parentNode);
1027         },
1028
1029         handleCreateKeydown: function(ev) {
1030                 var input = ev.currentTarget,
1031                     sb = findParent(input, '.cbi-dropdown');
1032
1033                 switch (ev.keyCode) {
1034                 case 13:
1035                         ev.preventDefault();
1036
1037                         if (input.classList.contains('cbi-input-invalid'))
1038                                 return;
1039
1040                         this.createItems(sb, input.value);
1041                         input.value = '';
1042                         input.blur();
1043                         break;
1044                 }
1045         },
1046
1047         handleCreateFocus: function(ev) {
1048                 var input = ev.currentTarget,
1049                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1050                     sb = findParent(input, '.cbi-dropdown');
1051
1052                 if (cbox)
1053                         cbox.checked = true;
1054
1055                 sb.setAttribute('locked-in', '');
1056         },
1057
1058         handleCreateBlur: function(ev) {
1059                 var input = ev.currentTarget,
1060                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1061                     sb = findParent(input, '.cbi-dropdown');
1062
1063                 if (cbox)
1064                         cbox.checked = false;
1065
1066                 sb.removeAttribute('locked-in');
1067         },
1068
1069         handleCreateClick: function(ev) {
1070                 ev.currentTarget.querySelector(this.options.create_query).focus();
1071         },
1072
1073         setValue: function(values) {
1074                 if (this.options.multiple) {
1075                         if (!Array.isArray(values))
1076                                 values = (values != null && values != '') ? [ values ] : [];
1077
1078                         var v = {};
1079
1080                         for (var i = 0; i < values.length; i++)
1081                                 v[values[i]] = true;
1082
1083                         this.setValues(this.node, v);
1084                 }
1085                 else {
1086                         var v = {};
1087
1088                         if (values != null) {
1089                                 if (Array.isArray(values))
1090                                         v[values[0]] = true;
1091                                 else
1092                                         v[values] = true;
1093                         }
1094
1095                         this.setValues(this.node, v);
1096                 }
1097         },
1098
1099         getValue: function() {
1100                 var div = this.node.lastElementChild,
1101                     h = div.querySelectorAll('input[type="hidden"]'),
1102                         v = [];
1103
1104                 for (var i = 0; i < h.length; i++)
1105                         v.push(h[i].value);
1106
1107                 return this.options.multiple ? v : v[0];
1108         }
1109 });
1110
1111 var UICombobox = UIDropdown.extend({
1112         __init__: function(value, choices, options) {
1113                 this.super('__init__', [ value, choices, Object.assign({
1114                         select_placeholder: _('-- Please choose --'),
1115                         custom_placeholder: _('-- custom --'),
1116                         dropdown_items: -1,
1117                         sort: true
1118                 }, options, {
1119                         multiple: false,
1120                         create: true,
1121                         optional: true
1122                 }) ]);
1123         }
1124 });
1125
1126 var UIDynamicList = UIElement.extend({
1127         __init__: function(values, choices, options) {
1128                 if (!Array.isArray(values))
1129                         values = (values != null && values != '') ? [ values ] : [];
1130
1131                 if (typeof(choices) != 'object')
1132                         choices = null;
1133
1134                 this.values = values;
1135                 this.choices = choices;
1136                 this.options = Object.assign({}, options, {
1137                         multiple: false,
1138                         optional: true
1139                 });
1140         },
1141
1142         render: function() {
1143                 var dl = E('div', {
1144                         'id': this.options.id,
1145                         'class': 'cbi-dynlist'
1146                 }, E('div', { 'class': 'add-item' }));
1147
1148                 if (this.choices) {
1149                         var cbox = new UICombobox(null, this.choices, this.options);
1150                         dl.lastElementChild.appendChild(cbox.render());
1151                 }
1152                 else {
1153                         var inputEl = E('input', {
1154                                 'id': this.options.id ? 'widget.' + this.options.id : null,
1155                                 'type': 'text',
1156                                 'class': 'cbi-input-text',
1157                                 'placeholder': this.options.placeholder
1158                         });
1159
1160                         dl.lastElementChild.appendChild(inputEl);
1161                         dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
1162
1163                         if (this.options.datatype)
1164                                 L.ui.addValidator(inputEl, this.options.datatype,
1165                                                   true, null, 'blur', 'keyup');
1166                 }
1167
1168                 for (var i = 0; i < this.values.length; i++)
1169                         this.addItem(dl, this.values[i],
1170                                 this.choices ? this.choices[this.values[i]] : null);
1171
1172                 return this.bind(dl);
1173         },
1174
1175         bind: function(dl) {
1176                 dl.addEventListener('click', L.bind(this.handleClick, this));
1177                 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
1178                 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
1179
1180                 this.node = dl;
1181
1182                 this.setUpdateEvents(dl, 'cbi-dynlist-change');
1183                 this.setChangeEvents(dl, 'cbi-dynlist-change');
1184
1185                 L.dom.bindClassInstance(dl, this);
1186
1187                 return dl;
1188         },
1189
1190         addItem: function(dl, value, text, flash) {
1191                 var exists = false,
1192                     new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
1193                                 E('span', {}, text || value),
1194                                 E('input', {
1195                                         'type': 'hidden',
1196                                         'name': this.options.name,
1197                                         'value': value })]);
1198
1199                 dl.querySelectorAll('.item, .add-item').forEach(function(item) {
1200                         if (exists)
1201                                 return;
1202
1203                         var hidden = item.querySelector('input[type="hidden"]');
1204
1205                         if (hidden && hidden.parentNode !== item)
1206                                 hidden = null;
1207
1208                         if (hidden && hidden.value === value)
1209                                 exists = true;
1210                         else if (!hidden || hidden.value >= value)
1211                                 exists = !!item.parentNode.insertBefore(new_item, item);
1212                 });
1213
1214                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1215                         bubbles: true,
1216                         detail: {
1217                                 instance: this,
1218                                 element: dl,
1219                                 value: value,
1220                                 add: true
1221                         }
1222                 }));
1223         },
1224
1225         removeItem: function(dl, item) {
1226                 var value = item.querySelector('input[type="hidden"]').value;
1227                 var sb = dl.querySelector('.cbi-dropdown');
1228                 if (sb)
1229                         sb.querySelectorAll('ul > li').forEach(function(li) {
1230                                 if (li.getAttribute('data-value') === value) {
1231                                         if (li.hasAttribute('dynlistcustom'))
1232                                                 li.parentNode.removeChild(li);
1233                                         else
1234                                                 li.removeAttribute('unselectable');
1235                                 }
1236                         });
1237
1238                 item.parentNode.removeChild(item);
1239
1240                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1241                         bubbles: true,
1242                         detail: {
1243                                 instance: this,
1244                                 element: dl,
1245                                 value: value,
1246                                 remove: true
1247                         }
1248                 }));
1249         },
1250
1251         handleClick: function(ev) {
1252                 var dl = ev.currentTarget,
1253                     item = findParent(ev.target, '.item');
1254
1255                 if (item) {
1256                         this.removeItem(dl, item);
1257                 }
1258                 else if (matchesElem(ev.target, '.cbi-button-add')) {
1259                         var input = ev.target.previousElementSibling;
1260                         if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
1261                                 this.addItem(dl, input.value, null, true);
1262                                 input.value = '';
1263                         }
1264                 }
1265         },
1266
1267         handleDropdownChange: function(ev) {
1268                 var dl = ev.currentTarget,
1269                     sbIn = ev.detail.instance,
1270                     sbEl = ev.detail.element,
1271                     sbVal = ev.detail.value;
1272
1273                 if (sbVal === null)
1274                         return;
1275
1276                 sbIn.setValues(sbEl, null);
1277                 sbVal.element.setAttribute('unselectable', '');
1278
1279                 if (sbVal.element.hasAttribute('created')) {
1280                         sbVal.element.removeAttribute('created');
1281                         sbVal.element.setAttribute('dynlistcustom', '');
1282                 }
1283
1284                 this.addItem(dl, sbVal.value, sbVal.text, true);
1285         },
1286
1287         handleKeydown: function(ev) {
1288                 var dl = ev.currentTarget,
1289                     item = findParent(ev.target, '.item');
1290
1291                 if (item) {
1292                         switch (ev.keyCode) {
1293                         case 8: /* backspace */
1294                                 if (item.previousElementSibling)
1295                                         item.previousElementSibling.focus();
1296
1297                                 this.removeItem(dl, item);
1298                                 break;
1299
1300                         case 46: /* delete */
1301                                 if (item.nextElementSibling) {
1302                                         if (item.nextElementSibling.classList.contains('item'))
1303                                                 item.nextElementSibling.focus();
1304                                         else
1305                                                 item.nextElementSibling.firstElementChild.focus();
1306                                 }
1307
1308                                 this.removeItem(dl, item);
1309                                 break;
1310                         }
1311                 }
1312                 else if (matchesElem(ev.target, '.cbi-input-text')) {
1313                         switch (ev.keyCode) {
1314                         case 13: /* enter */
1315                                 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
1316                                         this.addItem(dl, ev.target.value, null, true);
1317                                         ev.target.value = '';
1318                                         ev.target.blur();
1319                                         ev.target.focus();
1320                                 }
1321
1322                                 ev.preventDefault();
1323                                 break;
1324                         }
1325                 }
1326         },
1327
1328         getValue: function() {
1329                 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
1330                     v = [];
1331
1332                 for (var i = 0; i < items.length; i++)
1333                         v.push(items[i].value);
1334
1335                 return v;
1336         },
1337
1338         setValue: function(values) {
1339                 if (!Array.isArray(values))
1340                         values = (values != null && values != '') ? [ values ] : [];
1341
1342                 var items = this.node.querySelectorAll('.item');
1343
1344                 for (var i = 0; i < items.length; i++)
1345                         if (items[i].parentNode === this.node)
1346                                 this.removeItem(this.node, items[i]);
1347
1348                 for (var i = 0; i < values.length; i++)
1349                         this.addItem(this.node, values[i],
1350                                 this.choices ? this.choices[values[i]] : null);
1351         }
1352 });
1353
1354 var UIHiddenfield = UIElement.extend({
1355         __init__: function(value, options) {
1356                 this.value = value;
1357                 this.options = Object.assign({
1358
1359                 }, options);
1360         },
1361
1362         render: function() {
1363                 var hiddenEl = E('input', {
1364                         'id': this.options.id,
1365                         'type': 'hidden',
1366                         'value': this.value
1367                 });
1368
1369                 return this.bind(hiddenEl);
1370         },
1371
1372         bind: function(hiddenEl) {
1373                 this.node = hiddenEl;
1374
1375                 L.dom.bindClassInstance(hiddenEl, this);
1376
1377                 return hiddenEl;
1378         },
1379
1380         getValue: function() {
1381                 return this.node.value;
1382         },
1383
1384         setValue: function(value) {
1385                 this.node.value = value;
1386         }
1387 });
1388
1389
1390 return L.Class.extend({
1391         __init__: function() {
1392                 modalDiv = document.body.appendChild(
1393                         L.dom.create('div', { id: 'modal_overlay' },
1394                                 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
1395
1396                 tooltipDiv = document.body.appendChild(
1397                         L.dom.create('div', { class: 'cbi-tooltip' }));
1398
1399                 /* setup old aliases */
1400                 L.showModal = this.showModal;
1401                 L.hideModal = this.hideModal;
1402                 L.showTooltip = this.showTooltip;
1403                 L.hideTooltip = this.hideTooltip;
1404                 L.itemlist = this.itemlist;
1405
1406                 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
1407                 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
1408                 document.addEventListener('focus', this.showTooltip.bind(this), true);
1409                 document.addEventListener('blur', this.hideTooltip.bind(this), true);
1410
1411                 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
1412                 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
1413                 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
1414         },
1415
1416         /* Modal dialog */
1417         showModal: function(title, children /* , ... */) {
1418                 var dlg = modalDiv.firstElementChild;
1419
1420                 dlg.setAttribute('class', 'modal');
1421
1422                 for (var i = 2; i < arguments.length; i++)
1423                         dlg.classList.add(arguments[i]);
1424
1425                 L.dom.content(dlg, L.dom.create('h4', {}, title));
1426                 L.dom.append(dlg, children);
1427
1428                 document.body.classList.add('modal-overlay-active');
1429
1430                 return dlg;
1431         },
1432
1433         hideModal: function() {
1434                 document.body.classList.remove('modal-overlay-active');
1435         },
1436
1437         /* Tooltip */
1438         showTooltip: function(ev) {
1439                 var target = findParent(ev.target, '[data-tooltip]');
1440
1441                 if (!target)
1442                         return;
1443
1444                 if (tooltipTimeout !== null) {
1445                         window.clearTimeout(tooltipTimeout);
1446                         tooltipTimeout = null;
1447                 }
1448
1449                 var rect = target.getBoundingClientRect(),
1450                     x = rect.left              + window.pageXOffset,
1451                     y = rect.top + rect.height + window.pageYOffset;
1452
1453                 tooltipDiv.className = 'cbi-tooltip';
1454                 tooltipDiv.innerHTML = '▲ ';
1455                 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
1456
1457                 if (target.hasAttribute('data-tooltip-style'))
1458                         tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
1459
1460                 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
1461                         y -= (tooltipDiv.offsetHeight + target.offsetHeight);
1462                         tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
1463                 }
1464
1465                 tooltipDiv.style.top = y + 'px';
1466                 tooltipDiv.style.left = x + 'px';
1467                 tooltipDiv.style.opacity = 1;
1468
1469                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
1470                         bubbles: true,
1471                         detail: { target: target }
1472                 }));
1473         },
1474
1475         hideTooltip: function(ev) {
1476                 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
1477                     tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
1478                         return;
1479
1480                 if (tooltipTimeout !== null) {
1481                         window.clearTimeout(tooltipTimeout);
1482                         tooltipTimeout = null;
1483                 }
1484
1485                 tooltipDiv.style.opacity = 0;
1486                 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
1487
1488                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
1489         },
1490
1491         /* Widget helper */
1492         itemlist: function(node, items, separators) {
1493                 var children = [];
1494
1495                 if (!Array.isArray(separators))
1496                         separators = [ separators || E('br') ];
1497
1498                 for (var i = 0; i < items.length; i += 2) {
1499                         if (items[i+1] !== null && items[i+1] !== undefined) {
1500                                 var sep = separators[(i/2) % separators.length],
1501                                     cld = [];
1502
1503                                 children.push(E('span', { class: 'nowrap' }, [
1504                                         items[i] ? E('strong', items[i] + ': ') : '',
1505                                         items[i+1]
1506                                 ]));
1507
1508                                 if ((i+2) < items.length)
1509                                         children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
1510                         }
1511                 }
1512
1513                 L.dom.content(node, children);
1514
1515                 return node;
1516         },
1517
1518         /* Tabs */
1519         tabs: L.Class.singleton({
1520                 init: function() {
1521                         var groups = [], prevGroup = null, currGroup = null;
1522
1523                         document.querySelectorAll('[data-tab]').forEach(function(tab) {
1524                                 var parent = tab.parentNode;
1525
1526                                 if (!parent.hasAttribute('data-tab-group'))
1527                                         parent.setAttribute('data-tab-group', groups.length);
1528
1529                                 currGroup = +parent.getAttribute('data-tab-group');
1530
1531                                 if (currGroup !== prevGroup) {
1532                                         prevGroup = currGroup;
1533
1534                                         if (!groups[currGroup])
1535                                                 groups[currGroup] = [];
1536                                 }
1537
1538                                 groups[currGroup].push(tab);
1539                         });
1540
1541                         for (var i = 0; i < groups.length; i++)
1542                                 this.initTabGroup(groups[i]);
1543
1544                         document.addEventListener('dependency-update', this.updateTabs.bind(this));
1545
1546                         this.updateTabs();
1547
1548                         if (!groups.length)
1549                                 this.setActiveTabId(-1, -1);
1550                 },
1551
1552                 initTabGroup: function(panes) {
1553                         if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
1554                                 return;
1555
1556                         var menu = E('ul', { 'class': 'cbi-tabmenu' }),
1557                             group = panes[0].parentNode,
1558                             groupId = +group.getAttribute('data-tab-group'),
1559                             selected = null;
1560
1561                         for (var i = 0, pane; pane = panes[i]; i++) {
1562                                 var name = pane.getAttribute('data-tab'),
1563                                     title = pane.getAttribute('data-tab-title'),
1564                                     active = pane.getAttribute('data-tab-active') === 'true';
1565
1566                                 menu.appendChild(E('li', {
1567                                         'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
1568                                         'data-tab': name
1569                                 }, E('a', {
1570                                         'href': '#',
1571                                         'click': this.switchTab.bind(this)
1572                                 }, title)));
1573
1574                                 if (active)
1575                                         selected = i;
1576                         }
1577
1578                         group.parentNode.insertBefore(menu, group);
1579
1580                         if (selected === null) {
1581                                 selected = this.getActiveTabId(groupId);
1582
1583                                 if (selected < 0 || selected >= panes.length)
1584                                         selected = 0;
1585
1586                                 menu.childNodes[selected].classList.add('cbi-tab');
1587                                 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
1588                                 panes[selected].setAttribute('data-tab-active', 'true');
1589
1590                                 this.setActiveTabId(groupId, selected);
1591                         }
1592                 },
1593
1594                 getActiveTabState: function() {
1595                         var page = document.body.getAttribute('data-page');
1596
1597                         try {
1598                                 var val = JSON.parse(window.sessionStorage.getItem('tab'));
1599                                 if (val.page === page && Array.isArray(val.groups))
1600                                         return val;
1601                         }
1602                         catch(e) {}
1603
1604                         window.sessionStorage.removeItem('tab');
1605                         return { page: page, groups: [] };
1606                 },
1607
1608                 getActiveTabId: function(groupId) {
1609                         return +this.getActiveTabState().groups[groupId] || 0;
1610                 },
1611
1612                 setActiveTabId: function(groupId, tabIndex) {
1613                         try {
1614                                 var state = this.getActiveTabState();
1615                                     state.groups[groupId] = tabIndex;
1616
1617                             window.sessionStorage.setItem('tab', JSON.stringify(state));
1618                         }
1619                         catch (e) { return false; }
1620
1621                         return true;
1622                 },
1623
1624                 updateTabs: function(ev) {
1625                         document.querySelectorAll('[data-tab-title]').forEach(function(pane) {
1626                                 var menu = pane.parentNode.previousElementSibling,
1627                                     tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))),
1628                                     n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
1629
1630                                 if (!pane.firstElementChild) {
1631                                         tab.style.display = 'none';
1632                                         tab.classList.remove('flash');
1633                                 }
1634                                 else if (tab.style.display === 'none') {
1635                                         tab.style.display = '';
1636                                         requestAnimationFrame(function() { tab.classList.add('flash') });
1637                                 }
1638
1639                                 if (n_errors) {
1640                                         tab.setAttribute('data-errors', n_errors);
1641                                         tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
1642                                         tab.setAttribute('data-tooltip-style', 'error');
1643                                 }
1644                                 else {
1645                                         tab.removeAttribute('data-errors');
1646                                         tab.removeAttribute('data-tooltip');
1647                                 }
1648                         });
1649                 },
1650
1651                 switchTab: function(ev) {
1652                         var tab = ev.target.parentNode,
1653                             name = tab.getAttribute('data-tab'),
1654                             menu = tab.parentNode,
1655                             group = menu.nextElementSibling,
1656                             groupId = +group.getAttribute('data-tab-group'),
1657                             index = 0;
1658
1659                         ev.preventDefault();
1660
1661                         if (!tab.classList.contains('cbi-tab-disabled'))
1662                                 return;
1663
1664                         menu.querySelectorAll('[data-tab]').forEach(function(tab) {
1665                                 tab.classList.remove('cbi-tab');
1666                                 tab.classList.remove('cbi-tab-disabled');
1667                                 tab.classList.add(
1668                                         tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
1669                         });
1670
1671                         group.childNodes.forEach(function(pane) {
1672                                 if (L.dom.matches(pane, '[data-tab]')) {
1673                                         if (pane.getAttribute('data-tab') === name) {
1674                                                 pane.setAttribute('data-tab-active', 'true');
1675                                                 L.ui.tabs.setActiveTabId(groupId, index);
1676                                         }
1677                                         else {
1678                                                 pane.setAttribute('data-tab-active', 'false');
1679                                         }
1680
1681                                         index++;
1682                                 }
1683                         });
1684                 }
1685         }),
1686
1687         /* UCI Changes */
1688         changes: L.Class.singleton({
1689                 init: function() {
1690                         if (!L.env.sessionid)
1691                                 return;
1692
1693                         return uci.changes().then(L.bind(this.renderChangeIndicator, this));
1694                 },
1695
1696                 setIndicator: function(n) {
1697                         var i = document.querySelector('.uci_change_indicator');
1698                         if (i == null) {
1699                                 var poll = document.getElementById('xhr_poll_status');
1700                                 i = poll.parentNode.insertBefore(E('a', {
1701                                         'href': '#',
1702                                         'class': 'uci_change_indicator label notice',
1703                                         'click': L.bind(this.displayChanges, this)
1704                                 }), poll);
1705                         }
1706
1707                         if (n > 0) {
1708                                 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
1709                                 i.classList.add('flash');
1710                                 i.style.display = '';
1711                         }
1712                         else {
1713                                 i.classList.remove('flash');
1714                                 i.style.display = 'none';
1715                         }
1716                 },
1717
1718                 renderChangeIndicator: function(changes) {
1719                         var n_changes = 0;
1720
1721                         for (var config in changes)
1722                                 if (changes.hasOwnProperty(config))
1723                                         n_changes += changes[config].length;
1724
1725                         this.changes = changes;
1726                         this.setIndicator(n_changes);
1727                 },
1728
1729                 changeTemplates: {
1730                         'add-3':      '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
1731                         'set-3':      '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
1732                         'set-4':      '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
1733                         'remove-2':   '<del>uci del %0.<strong>%2</strong></del>',
1734                         'remove-3':   '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
1735                         'order-3':    '<var>uci reorder %0.%2=<strong>%3</strong></var>',
1736                         'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
1737                         'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
1738                         'rename-3':   '<var>uci rename %0.%2=<strong>%3</strong></var>',
1739                         'rename-4':   '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
1740                 },
1741
1742                 displayChanges: function() {
1743                         var list = E('div', { 'class': 'uci-change-list' }),
1744                             dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
1745                                 E('div', { 'class': 'cbi-section' }, [
1746                                         E('strong', _('Legend:')),
1747                                         E('div', { 'class': 'uci-change-legend' }, [
1748                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1749                                                         E('ins', '&#160;'), ' ', _('Section added') ]),
1750                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1751                                                         E('del', '&#160;'), ' ', _('Section removed') ]),
1752                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1753                                                         E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
1754                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1755                                                         E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
1756                                         E('br'), list,
1757                                         E('div', { 'class': 'right' }, [
1758                                                 E('input', {
1759                                                         'type': 'button',
1760                                                         'class': 'btn',
1761                                                         'click': L.ui.hideModal,
1762                                                         'value': _('Dismiss')
1763                                                 }), ' ',
1764                                                 E('input', {
1765                                                         'type': 'button',
1766                                                         'class': 'cbi-button cbi-button-positive important',
1767                                                         'click': L.bind(this.apply, this, true),
1768                                                         'value': _('Save & Apply')
1769                                                 }), ' ',
1770                                                 E('input', {
1771                                                         'type': 'button',
1772                                                         'class': 'cbi-button cbi-button-reset',
1773                                                         'click': L.bind(this.revert, this),
1774                                                         'value': _('Revert')
1775                                                 })])])
1776                         ]);
1777
1778                         for (var config in this.changes) {
1779                                 if (!this.changes.hasOwnProperty(config))
1780                                         continue;
1781
1782                                 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
1783
1784                                 for (var i = 0, added = null; i < this.changes[config].length; i++) {
1785                                         var chg = this.changes[config][i],
1786                                             tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
1787
1788                                         list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
1789                                                 switch (+m1) {
1790                                                 case 0:
1791                                                         return config;
1792
1793                                                 case 2:
1794                                                         if (added != null && chg[1] == added[0])
1795                                                                 return '@' + added[1] + '[-1]';
1796                                                         else
1797                                                                 return chg[1];
1798
1799                                                 case 4:
1800                                                         return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
1801
1802                                                 default:
1803                                                         return chg[m1-1];
1804                                                 }
1805                                         })));
1806
1807                                         if (chg[0] == 'add')
1808                                                 added = [ chg[1], chg[2] ];
1809                                 }
1810                         }
1811
1812                         list.appendChild(E('br'));
1813                         dlg.classList.add('uci-dialog');
1814                 },
1815
1816                 displayStatus: function(type, content) {
1817                         if (type) {
1818                                 var message = L.ui.showModal('', '');
1819
1820                                 message.classList.add('alert-message');
1821                                 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
1822
1823                                 if (content)
1824                                         L.dom.content(message, content);
1825
1826                                 if (!this.was_polling) {
1827                                         this.was_polling = L.Request.poll.active();
1828                                         L.Request.poll.stop();
1829                                 }
1830                         }
1831                         else {
1832                                 L.ui.hideModal();
1833
1834                                 if (this.was_polling)
1835                                         L.Request.poll.start();
1836                         }
1837                 },
1838
1839                 rollback: function(checked) {
1840                         if (checked) {
1841                                 this.displayStatus('warning spinning',
1842                                         E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
1843                                                 .format(L.env.apply_rollback)));
1844
1845                                 var call = function(r, data, duration) {
1846                                         if (r.status === 204) {
1847                                                 L.ui.changes.displayStatus('warning', [
1848                                                         E('h4', _('Configuration has been rolled back!')),
1849                                                         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)),
1850                                                         E('div', { 'class': 'right' }, [
1851                                                                 E('input', {
1852                                                                         'type': 'button',
1853                                                                         'class': 'btn',
1854                                                                         'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false),
1855                                                                         'value': _('Dismiss')
1856                                                                 }), ' ',
1857                                                                 E('input', {
1858                                                                         'type': 'button',
1859                                                                         'class': 'btn cbi-button-action important',
1860                                                                         'click': L.bind(L.ui.changes.revert, L.ui.changes),
1861                                                                         'value': _('Revert changes')
1862                                                                 }), ' ',
1863                                                                 E('input', {
1864                                                                         'type': 'button',
1865                                                                         'class': 'btn cbi-button-negative important',
1866                                                                         'click': L.bind(L.ui.changes.apply, L.ui.changes, false),
1867                                                                         'value': _('Apply unchecked')
1868                                                                 })
1869                                                         ])
1870                                                 ]);
1871
1872                                                 return;
1873                                         }
1874
1875                                         var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
1876                                         window.setTimeout(function() {
1877                                                 L.Request.request(L.url('admin/uci/confirm'), {
1878                                                         method: 'post',
1879                                                         timeout: L.env.apply_timeout * 1000,
1880                                                         query: { sid: L.env.sessionid, token: L.env.token }
1881                                                 }).then(call);
1882                                         }, delay);
1883                                 };
1884
1885                                 call({ status: 0 });
1886                         }
1887                         else {
1888                                 this.displayStatus('warning', [
1889                                         E('h4', _('Device unreachable!')),
1890                                         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.'))
1891                                 ]);
1892                         }
1893                 },
1894
1895                 confirm: function(checked, deadline, override_token) {
1896                         var tt;
1897                         var ts = Date.now();
1898
1899                         this.displayStatus('notice');
1900
1901                         if (override_token)
1902                                 this.confirm_auth = { token: override_token };
1903
1904                         var call = function(r, data, duration) {
1905                                 if (Date.now() >= deadline) {
1906                                         window.clearTimeout(tt);
1907                                         L.ui.changes.rollback(checked);
1908                                         return;
1909                                 }
1910                                 else if (r && (r.status === 200 || r.status === 204)) {
1911                                         document.dispatchEvent(new CustomEvent('uci-applied'));
1912
1913                                         L.ui.changes.setIndicator(0);
1914                                         L.ui.changes.displayStatus('notice',
1915                                                 E('p', _('Configuration has been applied.')));
1916
1917                                         window.clearTimeout(tt);
1918                                         window.setTimeout(function() {
1919                                                 //L.ui.changes.displayStatus(false);
1920                                                 window.location = window.location.href.split('#')[0];
1921                                         }, L.env.apply_display * 1000);
1922
1923                                         return;
1924                                 }
1925
1926                                 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
1927                                 window.setTimeout(function() {
1928                                         L.Request.request(L.url('admin/uci/confirm'), {
1929                                                 method: 'post',
1930                                                 timeout: L.env.apply_timeout * 1000,
1931                                                 query: L.ui.changes.confirm_auth
1932                                         }).then(call);
1933                                 }, delay);
1934                         };
1935
1936                         var tick = function() {
1937                                 var now = Date.now();
1938
1939                                 L.ui.changes.displayStatus('notice spinning',
1940                                         E('p', _('Waiting for configuration to get applied… %ds')
1941                                                 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
1942
1943                                 if (now >= deadline)
1944                                         return;
1945
1946                                 tt = window.setTimeout(tick, 1000 - (now - ts));
1947                                 ts = now;
1948                         };
1949
1950                         tick();
1951
1952                         /* wait a few seconds for the settings to become effective */
1953                         window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
1954                 },
1955
1956                 apply: function(checked) {
1957                         this.displayStatus('notice spinning',
1958                                 E('p', _('Starting configuration apply…')));
1959
1960                         L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
1961                                 method: 'post',
1962                                 query: { sid: L.env.sessionid, token: L.env.token }
1963                         }).then(function(r) {
1964                                 if (r.status === (checked ? 200 : 204)) {
1965                                         var tok = null; try { tok = r.json(); } catch(e) {}
1966                                         if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
1967                                                 L.ui.changes.confirm_auth = tok;
1968
1969                                         L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
1970                                 }
1971                                 else if (checked && r.status === 204) {
1972                                         L.ui.changes.displayStatus('notice',
1973                                                 E('p', _('There are no changes to apply')));
1974
1975                                         window.setTimeout(function() {
1976                                                 L.ui.changes.displayStatus(false);
1977                                         }, L.env.apply_display * 1000);
1978                                 }
1979                                 else {
1980                                         L.ui.changes.displayStatus('warning',
1981                                                 E('p', _('Apply request failed with status <code>%h</code>')
1982                                                         .format(r.responseText || r.statusText || r.status)));
1983
1984                                         window.setTimeout(function() {
1985                                                 L.ui.changes.displayStatus(false);
1986                                         }, L.env.apply_display * 1000);
1987                                 }
1988                         });
1989                 },
1990
1991                 revert: function() {
1992                         this.displayStatus('notice spinning',
1993                                 E('p', _('Reverting configuration…')));
1994
1995                         L.Request.request(L.url('admin/uci/revert'), {
1996                                 method: 'post',
1997                                 query: { sid: L.env.sessionid, token: L.env.token }
1998                         }).then(function(r) {
1999                                 if (r.status === 200) {
2000                                         document.dispatchEvent(new CustomEvent('uci-reverted'));
2001
2002                                         L.ui.changes.setIndicator(0);
2003                                         L.ui.changes.displayStatus('notice',
2004                                                 E('p', _('Changes have been reverted.')));
2005
2006                                         window.setTimeout(function() {
2007                                                 //L.ui.changes.displayStatus(false);
2008                                                 window.location = window.location.href.split('#')[0];
2009                                         }, L.env.apply_display * 1000);
2010                                 }
2011                                 else {
2012                                         L.ui.changes.displayStatus('warning',
2013                                                 E('p', _('Revert request failed with status <code>%h</code>')
2014                                                         .format(r.statusText || r.status)));
2015
2016                                         window.setTimeout(function() {
2017                                                 L.ui.changes.displayStatus(false);
2018                                         }, L.env.apply_display * 1000);
2019                                 }
2020                         });
2021                 }
2022         }),
2023
2024         addValidator: function(field, type, optional, vfunc /*, ... */) {
2025                 if (type == null)
2026                         return;
2027
2028                 var events = this.varargs(arguments, 3);
2029                 if (events.length == 0)
2030                         events.push('blur', 'keyup');
2031
2032                 try {
2033                         var cbiValidator = L.validation.create(field, type, optional, vfunc),
2034                             validatorFn = cbiValidator.validate.bind(cbiValidator);
2035
2036                         for (var i = 0; i < events.length; i++)
2037                                 field.addEventListener(events[i], validatorFn);
2038
2039                         validatorFn();
2040
2041                         return validatorFn;
2042                 }
2043                 catch (e) { }
2044         },
2045
2046         /* Widgets */
2047         Textfield: UITextfield,
2048         Checkbox: UICheckbox,
2049         Select: UISelect,
2050         Dropdown: UIDropdown,
2051         DynamicList: UIDynamicList,
2052         Combobox: UICombobox,
2053         Hiddenfield: UIHiddenfield
2054 });