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