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