luci-base: ui.js: add generic indicator handling functions
[oweals/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2 'require validation';
3 'require baseclass';
4 'require request';
5 'require poll';
6 'require dom';
7 'require rpc';
8 'require uci';
9 'require fs';
10
11 var modalDiv = null,
12     tooltipDiv = null,
13     indicatorDiv = null,
14     tooltipTimeout = null;
15
16 /**
17  * @class AbstractElement
18  * @memberof LuCI.ui
19  * @hideconstructor
20  * @classdesc
21  *
22  * The `AbstractElement` class serves as abstract base for the different widgets
23  * implemented by `LuCI.ui`. It provides the common logic for getting and
24  * setting values, for checking the validity state and for wiring up required
25  * events.
26  *
27  * UI widget instances are usually not supposed to be created by view code
28  * directly, instead they're implicitely created by `LuCI.form` when
29  * instantiating CBI forms.
30  *
31  * This class is automatically instantiated as part of `LuCI.ui`. To use it
32  * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
33  * it in external JavaScript, use `L.require("ui").then(...)` and access the
34  * `AbstractElement` property of the class instance value.
35  */
36 var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
37         /**
38          * @typedef {Object} InitOptions
39          * @memberof LuCI.ui.AbstractElement
40          *
41          * @property {string} [id]
42          * Specifies the widget ID to use. It will be used as HTML `id` attribute
43          * on the toplevel widget DOM node.
44          *
45          * @property {string} [name]
46          * Specifies the widget name which is set as HTML `name` attribute on the
47          * corresponding `<input>` element.
48          *
49          * @property {boolean} [optional=true]
50          * Specifies whether the input field allows empty values.
51          *
52          * @property {string} [datatype=string]
53          * An expression describing the input data validation constraints.
54          * It defaults to `string` which will allow any value.
55          * See {@link LuCI.validation} for details on the expression format.
56          *
57          * @property {function} [validator]
58          * Specifies a custom validator function which is invoked after the
59          * standard validation constraints are checked. The function should return
60          * `true` to accept the given input value. Any other return value type is
61          * converted to a string and treated as validation error message.
62          */
63
64         /**
65          * Read the current value of the input widget.
66          *
67          * @instance
68          * @memberof LuCI.ui.AbstractElement
69          * @returns {string|string[]|null}
70          * The current value of the input element. For simple inputs like text
71          * fields or selects, the return value type will be a - possibly empty -
72          * string. Complex widgets such as `DynamicList` instances may result in
73          * an array of strings or `null` for unset values.
74          */
75         getValue: function() {
76                 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
77                         return this.node.value;
78
79                 return null;
80         },
81
82         /**
83          * Set the current value of the input widget.
84          *
85          * @instance
86          * @memberof LuCI.ui.AbstractElement
87          * @param {string|string[]|null} value
88          * The value to set the input element to. For simple inputs like text
89          * fields or selects, the value should be a - possibly empty - string.
90          * Complex widgets such as `DynamicList` instances may accept string array
91          * or `null` values.
92          */
93         setValue: function(value) {
94                 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
95                         this.node.value = value;
96         },
97
98         /**
99          * Check whether the current input value is valid.
100          *
101          * @instance
102          * @memberof LuCI.ui.AbstractElement
103          * @returns {boolean}
104          * Returns `true` if the current input value is valid or `false` if it does
105          * not meet the validation constraints.
106          */
107         isValid: function() {
108                 return (this.validState !== false);
109         },
110
111         /**
112          * Force validation of the current input value.
113          *
114          * Usually input validation is automatically triggered by various DOM events
115          * bound to the input widget. In some cases it is required though to manually
116          * trigger validation runs, e.g. when programmatically altering values.
117          *
118          * @instance
119          * @memberof LuCI.ui.AbstractElement
120          */
121         triggerValidation: function() {
122                 if (typeof(this.vfunc) != 'function')
123                         return false;
124
125                 var wasValid = this.isValid();
126
127                 this.vfunc();
128
129                 return (wasValid != this.isValid());
130         },
131
132         /**
133          * Dispatch a custom (synthetic) event in response to received events.
134          *
135          * Sets up event handlers on the given target DOM node for the given event
136          * names that dispatch a custom event of the given type to the widget root
137          * DOM node.
138          *
139          * The primary purpose of this function is to set up a series of custom
140          * uniform standard events such as `widget-update`, `validation-success`,
141          * `validation-failure` etc. which are triggered by various different
142          * widget specific native DOM events.
143          *
144          * @instance
145          * @memberof LuCI.ui.AbstractElement
146          * @param {Node} targetNode
147          * Specifies the DOM node on which the native event listeners should be
148          * registered.
149          *
150          * @param {string} synevent
151          * The name of the custom event to dispatch to the widget root DOM node.
152          *
153          * @param {string[]} events
154          * The native DOM events for which event handlers should be registered.
155          */
156         registerEvents: function(targetNode, synevent, events) {
157                 var dispatchFn = L.bind(function(ev) {
158                         this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
159                 }, this);
160
161                 for (var i = 0; i < events.length; i++)
162                         targetNode.addEventListener(events[i], dispatchFn);
163         },
164
165         /**
166          * Setup listeners for native DOM events that may update the widget value.
167          *
168          * Sets up event handlers on the given target DOM node for the given event
169          * names which may cause the input value to update, such as `keyup` or
170          * `onclick` events. In contrast to change events, such update events will
171          * trigger input value validation.
172          *
173          * @instance
174          * @memberof LuCI.ui.AbstractElement
175          * @param {Node} targetNode
176          * Specifies the DOM node on which the event listeners should be registered.
177          *
178          * @param {...string} events
179          * The DOM events for which event handlers should be registered.
180          */
181         setUpdateEvents: function(targetNode /*, ... */) {
182                 var datatype = this.options.datatype,
183                     optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
184                     validate = this.options.validate,
185                     events = this.varargs(arguments, 1);
186
187                 this.registerEvents(targetNode, 'widget-update', events);
188
189                 if (!datatype && !validate)
190                         return;
191
192                 this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
193                         targetNode, datatype || 'string',
194                         optional, validate
195                 ].concat(events));
196
197                 this.node.addEventListener('validation-success', L.bind(function(ev) {
198                         this.validState = true;
199                 }, this));
200
201                 this.node.addEventListener('validation-failure', L.bind(function(ev) {
202                         this.validState = false;
203                 }, this));
204         },
205
206         /**
207          * Setup listeners for native DOM events that may change the widget value.
208          *
209          * Sets up event handlers on the given target DOM node for the given event
210          * names which may cause the input value to change completely, such as
211          * `change` events in a select menu. In contrast to update events, such
212          * change events will not trigger input value validation but they may cause
213          * field dependencies to get re-evaluated and will mark the input widget
214          * as dirty.
215          *
216          * @instance
217          * @memberof LuCI.ui.AbstractElement
218          * @param {Node} targetNode
219          * Specifies the DOM node on which the event listeners should be registered.
220          *
221          * @param {...string} events
222          * The DOM events for which event handlers should be registered.
223          */
224         setChangeEvents: function(targetNode /*, ... */) {
225                 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
226
227                 for (var i = 1; i < arguments.length; i++)
228                         targetNode.addEventListener(arguments[i], tag_changed);
229
230                 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
231         },
232
233         /**
234          * Render the widget, setup event listeners and return resulting markup.
235          *
236          * @instance
237          * @memberof LuCI.ui.AbstractElement
238          *
239          * @returns {Node}
240          * Returns a DOM Node or DocumentFragment containing the rendered
241          * widget markup.
242          */
243         render: function() {}
244 });
245
246 /**
247  * Instantiate a text input widget.
248  *
249  * @constructor Textfield
250  * @memberof LuCI.ui
251  * @augments LuCI.ui.AbstractElement
252  *
253  * @classdesc
254  *
255  * The `Textfield` class implements a standard single line text input field.
256  *
257  * UI widget instances are usually not supposed to be created by view code
258  * directly, instead they're implicitely created by `LuCI.form` when
259  * instantiating CBI forms.
260  *
261  * This class is automatically instantiated as part of `LuCI.ui`. To use it
262  * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
263  * external JavaScript, use `L.require("ui").then(...)` and access the
264  * `Textfield` property of the class instance value.
265  *
266  * @param {string} [value=null]
267  * The initial input value.
268  *
269  * @param {LuCI.ui.Textfield.InitOptions} [options]
270  * Object describing the widget specific options to initialize the input.
271  */
272 var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
273         /**
274          * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
275          * the following properties are recognized:
276          *
277          * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
278          * @memberof LuCI.ui.Textfield
279          *
280          * @property {boolean} [password=false]
281          * Specifies whether the input should be rendered as concealed password field.
282          *
283          * @property {boolean} [readonly=false]
284          * Specifies whether the input widget should be rendered readonly.
285          *
286          * @property {number} [maxlength]
287          * Specifies the HTML `maxlength` attribute to set on the corresponding
288          * `<input>` element. Note that this a legacy property that exists for
289          * compatibility reasons. It is usually better to `maxlength(N)` validation
290          * expression.
291          *
292          * @property {string} [placeholder]
293          * Specifies the HTML `placeholder` attribute which is displayed when the
294          * corresponding `<input>` element is empty.
295          */
296         __init__: function(value, options) {
297                 this.value = value;
298                 this.options = Object.assign({
299                         optional: true,
300                         password: false
301                 }, options);
302         },
303
304         /** @override */
305         render: function() {
306                 var frameEl = E('div', { 'id': this.options.id });
307
308                 if (this.options.password) {
309                         frameEl.classList.add('nowrap');
310                         frameEl.appendChild(E('input', {
311                                 'type': 'password',
312                                 'style': 'position:absolute; left:-100000px',
313                                 'aria-hidden': true,
314                                 'tabindex': -1,
315                                 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
316                         }));
317                 }
318
319                 frameEl.appendChild(E('input', {
320                         'id': this.options.id ? 'widget.' + this.options.id : null,
321                         'name': this.options.name,
322                         'type': this.options.password ? 'password' : 'text',
323                         'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
324                         'readonly': this.options.readonly ? '' : null,
325                         'maxlength': this.options.maxlength,
326                         'placeholder': this.options.placeholder,
327                         'value': this.value,
328                 }));
329
330                 if (this.options.password)
331                         frameEl.appendChild(E('button', {
332                                 'class': 'cbi-button cbi-button-neutral',
333                                 'title': _('Reveal/hide password'),
334                                 'aria-label': _('Reveal/hide password'),
335                                 'click': function(ev) {
336                                         var e = this.previousElementSibling;
337                                         e.type = (e.type === 'password') ? 'text' : 'password';
338                                         ev.preventDefault();
339                                 }
340                         }, '∗'));
341
342                 return this.bind(frameEl);
343         },
344
345         /** @private */
346         bind: function(frameEl) {
347                 var inputEl = frameEl.childNodes[+!!this.options.password];
348
349                 this.node = frameEl;
350
351                 this.setUpdateEvents(inputEl, 'keyup', 'blur');
352                 this.setChangeEvents(inputEl, 'change');
353
354                 dom.bindClassInstance(frameEl, this);
355
356                 return frameEl;
357         },
358
359         /** @override */
360         getValue: function() {
361                 var inputEl = this.node.childNodes[+!!this.options.password];
362                 return inputEl.value;
363         },
364
365         /** @override */
366         setValue: function(value) {
367                 var inputEl = this.node.childNodes[+!!this.options.password];
368                 inputEl.value = value;
369         }
370 });
371
372 /**
373  * Instantiate a textarea widget.
374  *
375  * @constructor Textarea
376  * @memberof LuCI.ui
377  * @augments LuCI.ui.AbstractElement
378  *
379  * @classdesc
380  *
381  * The `Textarea` class implements a multiline text area input field.
382  *
383  * UI widget instances are usually not supposed to be created by view code
384  * directly, instead they're implicitely created by `LuCI.form` when
385  * instantiating CBI forms.
386  *
387  * This class is automatically instantiated as part of `LuCI.ui`. To use it
388  * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
389  * external JavaScript, use `L.require("ui").then(...)` and access the
390  * `Textarea` property of the class instance value.
391  *
392  * @param {string} [value=null]
393  * The initial input value.
394  *
395  * @param {LuCI.ui.Textarea.InitOptions} [options]
396  * Object describing the widget specific options to initialize the input.
397  */
398 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
399         /**
400          * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
401          * the following properties are recognized:
402          *
403          * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
404          * @memberof LuCI.ui.Textarea
405          *
406          * @property {boolean} [readonly=false]
407          * Specifies whether the input widget should be rendered readonly.
408          *
409          * @property {string} [placeholder]
410          * Specifies the HTML `placeholder` attribute which is displayed when the
411          * corresponding `<textarea>` element is empty.
412          *
413          * @property {boolean} [monospace=false]
414          * Specifies whether a monospace font should be forced for the textarea
415          * contents.
416          *
417          * @property {number} [cols]
418          * Specifies the HTML `cols` attribute to set on the corresponding
419          * `<textarea>` element.
420          *
421          * @property {number} [rows]
422          * Specifies the HTML `rows` attribute to set on the corresponding
423          * `<textarea>` element.
424          *
425          * @property {boolean} [wrap=false]
426          * Specifies whether the HTML `wrap` attribute should be set.
427          */
428         __init__: function(value, options) {
429                 this.value = value;
430                 this.options = Object.assign({
431                         optional: true,
432                         wrap: false,
433                         cols: null,
434                         rows: null
435                 }, options);
436         },
437
438         /** @override */
439         render: function() {
440                 var frameEl = E('div', { 'id': this.options.id }),
441                     value = (this.value != null) ? String(this.value) : '';
442
443                 frameEl.appendChild(E('textarea', {
444                         'id': this.options.id ? 'widget.' + this.options.id : null,
445                         'name': this.options.name,
446                         'class': 'cbi-input-textarea',
447                         'readonly': this.options.readonly ? '' : null,
448                         'placeholder': this.options.placeholder,
449                         'style': !this.options.cols ? 'width:100%' : null,
450                         'cols': this.options.cols,
451                         'rows': this.options.rows,
452                         'wrap': this.options.wrap ? '' : null
453                 }, [ value ]));
454
455                 if (this.options.monospace)
456                         frameEl.firstElementChild.style.fontFamily = 'monospace';
457
458                 return this.bind(frameEl);
459         },
460
461         /** @private */
462         bind: function(frameEl) {
463                 var inputEl = frameEl.firstElementChild;
464
465                 this.node = frameEl;
466
467                 this.setUpdateEvents(inputEl, 'keyup', 'blur');
468                 this.setChangeEvents(inputEl, 'change');
469
470                 dom.bindClassInstance(frameEl, this);
471
472                 return frameEl;
473         },
474
475         /** @override */
476         getValue: function() {
477                 return this.node.firstElementChild.value;
478         },
479
480         /** @override */
481         setValue: function(value) {
482                 this.node.firstElementChild.value = value;
483         }
484 });
485
486 /**
487  * Instantiate a checkbox widget.
488  *
489  * @constructor Checkbox
490  * @memberof LuCI.ui
491  * @augments LuCI.ui.AbstractElement
492  *
493  * @classdesc
494  *
495  * The `Checkbox` class implements a simple checkbox input field.
496  *
497  * UI widget instances are usually not supposed to be created by view code
498  * directly, instead they're implicitely created by `LuCI.form` when
499  * instantiating CBI forms.
500  *
501  * This class is automatically instantiated as part of `LuCI.ui`. To use it
502  * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
503  * external JavaScript, use `L.require("ui").then(...)` and access the
504  * `Checkbox` property of the class instance value.
505  *
506  * @param {string} [value=null]
507  * The initial input value.
508  *
509  * @param {LuCI.ui.Checkbox.InitOptions} [options]
510  * Object describing the widget specific options to initialize the input.
511  */
512 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
513         /**
514          * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
515          * the following properties are recognized:
516          *
517          * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
518          * @memberof LuCI.ui.Checkbox
519          *
520          * @property {string} [value_enabled=1]
521          * Specifies the value corresponding to a checked checkbox.
522          *
523          * @property {string} [value_disabled=0]
524          * Specifies the value corresponding to an unchecked checkbox.
525          *
526          * @property {string} [hiddenname]
527          * Specifies the HTML `name` attribute of the hidden input backing the
528          * checkbox. This is a legacy property existing for compatibility reasons,
529          * it is required for HTML based form submissions.
530          */
531         __init__: function(value, options) {
532                 this.value = value;
533                 this.options = Object.assign({
534                         value_enabled: '1',
535                         value_disabled: '0'
536                 }, options);
537         },
538
539         /** @override */
540         render: function() {
541                 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
542                 var frameEl = E('div', {
543                         'id': this.options.id,
544                         'class': 'cbi-checkbox'
545                 });
546
547                 if (this.options.hiddenname)
548                         frameEl.appendChild(E('input', {
549                                 'type': 'hidden',
550                                 'name': this.options.hiddenname,
551                                 'value': 1
552                         }));
553
554                 frameEl.appendChild(E('input', {
555                         'id': id,
556                         'name': this.options.name,
557                         'type': 'checkbox',
558                         'value': this.options.value_enabled,
559                         'checked': (this.value == this.options.value_enabled) ? '' : null,
560                         'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
561                 }));
562
563                 frameEl.appendChild(E('label', { 'for': id }));
564
565                 return this.bind(frameEl);
566         },
567
568         /** @private */
569         bind: function(frameEl) {
570                 this.node = frameEl;
571
572                 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
573                 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
574
575                 dom.bindClassInstance(frameEl, this);
576
577                 return frameEl;
578         },
579
580         /**
581          * Test whether the checkbox is currently checked.
582          *
583          * @instance
584          * @memberof LuCI.ui.Checkbox
585          * @returns {boolean}
586          * Returns `true` when the checkbox is currently checked, otherwise `false`.
587          */
588         isChecked: function() {
589                 return this.node.lastElementChild.previousElementSibling.checked;
590         },
591
592         /** @override */
593         getValue: function() {
594                 return this.isChecked()
595                         ? this.options.value_enabled
596                         : this.options.value_disabled;
597         },
598
599         /** @override */
600         setValue: function(value) {
601                 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
602         }
603 });
604
605 /**
606  * Instantiate a select dropdown or checkbox/radiobutton group.
607  *
608  * @constructor Select
609  * @memberof LuCI.ui
610  * @augments LuCI.ui.AbstractElement
611  *
612  * @classdesc
613  *
614  * The `Select` class implements either a traditional HTML `<select>` element
615  * or a group of checkboxes or radio buttons, depending on whether multiple
616  * values are enabled or not.
617  *
618  * UI widget instances are usually not supposed to be created by view code
619  * directly, instead they're implicitely created by `LuCI.form` when
620  * instantiating CBI forms.
621  *
622  * This class is automatically instantiated as part of `LuCI.ui`. To use it
623  * in views, use `'require ui'` and refer to `ui.Select`. To import it in
624  * external JavaScript, use `L.require("ui").then(...)` and access the
625  * `Select` property of the class instance value.
626  *
627  * @param {string|string[]} [value=null]
628  * The initial input value(s).
629  *
630  * @param {Object<string, string>} choices
631  * Object containing the selectable choices of the widget. The object keys
632  * serve as values for the different choices while the values are used as
633  * choice labels.
634  *
635  * @param {LuCI.ui.Select.InitOptions} [options]
636  * Object describing the widget specific options to initialize the inputs.
637  */
638 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
639         /**
640          * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
641          * the following properties are recognized:
642          *
643          * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
644          * @memberof LuCI.ui.Select
645          *
646          * @property {boolean} [multiple=false]
647          * Specifies whether multiple choice values may be selected.
648          *
649          * @property {string} [widget=select]
650          * Specifies the kind of widget to render. May be either `select` or
651          * `individual`. When set to `select` an HTML `<select>` element will be
652          * used, otherwise a group of checkbox or radio button elements is created,
653          * depending on the value of the `multiple` option.
654          *
655          * @property {string} [orientation=horizontal]
656          * Specifies whether checkbox / radio button groups should be rendered
657          * in a `horizontal` or `vertical` manner. Does not apply to the `select`
658          * widget type.
659          *
660          * @property {boolean|string[]} [sort=false]
661          * Specifies if and how to sort choice values. If set to `true`, the choice
662          * values will be sorted alphabetically. If set to an array of strings, the
663          * choice sort order is derived from the array.
664          *
665          * @property {number} [size]
666          * Specifies the HTML `size` attribute to set on the `<select>` element.
667          * Only applicable to the `select` widget type.
668          *
669          * @property {string} [placeholder=-- Please choose --]
670          * Specifies a placeholder text which is displayed when no choice is
671          * selected yet. Only applicable to the `select` widget type.
672          */
673         __init__: function(value, choices, options) {
674                 if (!L.isObject(choices))
675                         choices = {};
676
677                 if (!Array.isArray(value))
678                         value = (value != null && value != '') ? [ value ] : [];
679
680                 if (!options.multiple && value.length > 1)
681                         value.length = 1;
682
683                 this.values = value;
684                 this.choices = choices;
685                 this.options = Object.assign({
686                         multiple: false,
687                         widget: 'select',
688                         orientation: 'horizontal'
689                 }, options);
690
691                 if (this.choices.hasOwnProperty(''))
692                         this.options.optional = true;
693         },
694
695         /** @override */
696         render: function() {
697                 var frameEl = E('div', { 'id': this.options.id }),
698                     keys = Object.keys(this.choices);
699
700                 if (this.options.sort === true)
701                         keys.sort();
702                 else if (Array.isArray(this.options.sort))
703                         keys = this.options.sort;
704
705                 if (this.options.widget == 'select') {
706                         frameEl.appendChild(E('select', {
707                                 'id': this.options.id ? 'widget.' + this.options.id : null,
708                                 'name': this.options.name,
709                                 'size': this.options.size,
710                                 'class': 'cbi-input-select',
711                                 'multiple': this.options.multiple ? '' : null
712                         }));
713
714                         if (this.options.optional)
715                                 frameEl.lastChild.appendChild(E('option', {
716                                         'value': '',
717                                         'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
718                                 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
719
720                         for (var i = 0; i < keys.length; i++) {
721                                 if (keys[i] == null || keys[i] == '')
722                                         continue;
723
724                                 frameEl.lastChild.appendChild(E('option', {
725                                         'value': keys[i],
726                                         'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
727                                 }, [ this.choices[keys[i]] || keys[i] ]));
728                         }
729                 }
730                 else {
731                         var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
732
733                         for (var i = 0; i < keys.length; i++) {
734                                 frameEl.appendChild(E('label', {}, [
735                                         E('input', {
736                                                 'id': this.options.id ? 'widget.' + this.options.id : null,
737                                                 'name': this.options.id || this.options.name,
738                                                 'type': this.options.multiple ? 'checkbox' : 'radio',
739                                                 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
740                                                 'value': keys[i],
741                                                 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null
742                                         }),
743                                         this.choices[keys[i]] || keys[i]
744                                 ]));
745
746                                 if (i + 1 == this.options.size)
747                                         frameEl.appendChild(brEl);
748                         }
749                 }
750
751                 return this.bind(frameEl);
752         },
753
754         /** @private */
755         bind: function(frameEl) {
756                 this.node = frameEl;
757
758                 if (this.options.widget == 'select') {
759                         this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
760                         this.setChangeEvents(frameEl.firstChild, 'change');
761                 }
762                 else {
763                         var radioEls = frameEl.querySelectorAll('input[type="radio"]');
764                         for (var i = 0; i < radioEls.length; i++) {
765                                 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
766                                 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
767                         }
768                 }
769
770                 dom.bindClassInstance(frameEl, this);
771
772                 return frameEl;
773         },
774
775         /** @override */
776         getValue: function() {
777                 if (this.options.widget == 'select')
778                         return this.node.firstChild.value;
779
780                 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
781                 for (var i = 0; i < radioEls.length; i++)
782                         if (radioEls[i].checked)
783                                 return radioEls[i].value;
784
785                 return null;
786         },
787
788         /** @override */
789         setValue: function(value) {
790                 if (this.options.widget == 'select') {
791                         if (value == null)
792                                 value = '';
793
794                         for (var i = 0; i < this.node.firstChild.options.length; i++)
795                                 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
796
797                         return;
798                 }
799
800                 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
801                 for (var i = 0; i < radioEls.length; i++)
802                         radioEls[i].checked = (radioEls[i].value == value);
803         }
804 });
805
806 /**
807  * Instantiate a rich dropdown choice widget.
808  *
809  * @constructor Dropdown
810  * @memberof LuCI.ui
811  * @augments LuCI.ui.AbstractElement
812  *
813  * @classdesc
814  *
815  * The `Dropdown` class implements a rich, stylable dropdown menu which
816  * supports non-text choice labels.
817  *
818  * UI widget instances are usually not supposed to be created by view code
819  * directly, instead they're implicitely created by `LuCI.form` when
820  * instantiating CBI forms.
821  *
822  * This class is automatically instantiated as part of `LuCI.ui`. To use it
823  * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
824  * external JavaScript, use `L.require("ui").then(...)` and access the
825  * `Dropdown` property of the class instance value.
826  *
827  * @param {string|string[]} [value=null]
828  * The initial input value(s).
829  *
830  * @param {Object<string, *>} choices
831  * Object containing the selectable choices of the widget. The object keys
832  * serve as values for the different choices while the values are used as
833  * choice labels.
834  *
835  * @param {LuCI.ui.Dropdown.InitOptions} [options]
836  * Object describing the widget specific options to initialize the dropdown.
837  */
838 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
839         /**
840          * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
841          * the following properties are recognized:
842          *
843          * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
844          * @memberof LuCI.ui.Dropdown
845          *
846          * @property {boolean} [optional=true]
847          * Specifies whether the dropdown selection is optional. In contrast to
848          * other widgets, the `optional` constraint of dropdowns works differently;
849          * instead of marking the widget invalid on empty values when set to `false`,
850          * the user is not allowed to deselect all choices.
851          *
852          * For single value dropdowns that means that no empty "please select"
853          * choice is offered and for multi value dropdowns, the last selected choice
854          * may not be deselected without selecting another choice first.
855          *
856          * @property {boolean} [multiple]
857          * Specifies whether multiple choice values may be selected. It defaults
858          * to `true` when an array is passed as input value to the constructor.
859          *
860          * @property {boolean|string[]} [sort=false]
861          * Specifies if and how to sort choice values. If set to `true`, the choice
862          * values will be sorted alphabetically. If set to an array of strings, the
863          * choice sort order is derived from the array.
864          *
865          * @property {string} [select_placeholder=-- Please choose --]
866          * Specifies a placeholder text which is displayed when no choice is
867          * selected yet.
868          *
869          * @property {string} [custom_placeholder=-- custom --]
870          * Specifies a placeholder text which is displayed in the text input
871          * field allowing to enter custom choice values. Only applicable if the
872          * `create` option is set to `true`.
873          *
874          * @property {boolean} [create=false]
875          * Specifies whether custom choices may be entered into the dropdown
876          * widget.
877          *
878          * @property {string} [create_query=.create-item-input]
879          * Specifies a CSS selector expression used to find the input element
880          * which is used to enter custom choice values. This should not normally
881          * be used except by widgets derived from the Dropdown class.
882          *
883          * @property {string} [create_template=script[type="item-template"]]
884          * Specifies a CSS selector expression used to find an HTML element
885          * serving as template for newly added custom choice values.
886          *
887          * Any `{{value}}` placeholder string within the template elements text
888          * content will be replaced by the user supplied choice value, the
889          * resulting string is parsed as HTML and appended to the end of the
890          * choice list. The template markup may specify one HTML element with a
891          * `data-label-placeholder` attribute which is replaced by a matching
892          * label value from the `choices` object or with the user supplied value
893          * itself in case `choices` contains no matching choice label.
894          *
895          * If the template element is not found or if no `create_template` selector
896          * expression is specified, the default markup for newly created elements is
897          * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
898          *
899          * @property {string} [create_markup]
900          * This property allows specifying the markup for custom choices directly
901          * instead of referring to a template element through CSS selectors.
902          *
903          * Apart from that it works exactly like `create_template`.
904          *
905          * @property {number} [display_items=3]
906          * Specifies the maximum amount of choice labels that should be shown in
907          * collapsed dropdown state before further selected choices are cut off.
908          *
909          * Only applicable when `multiple` is `true`.
910          *
911          * @property {number} [dropdown_items=-1]
912          * Specifies the maximum amount of choices that should be shown when the
913          * dropdown is open. If the amount of available choices exceeds this number,
914          * the dropdown area must be scrolled to reach further items.
915          *
916          * If set to `-1`, the dropdown menu will attempt to show all choice values
917          * and only resort to scrolling if the amount of choices exceeds the available
918          * screen space above and below the dropdown widget.
919          *
920          * @property {string} [placeholder]
921          * This property serves as a shortcut to set both `select_placeholder` and
922          * `custom_placeholder`. Either of these properties will fallback to
923          * `placeholder` if not specified.
924          *
925          * @property {boolean} [readonly=false]
926          * Specifies whether the custom choice input field should be rendered
927          * readonly. Only applicable when `create` is `true`.
928          *
929          * @property {number} [maxlength]
930          * Specifies the HTML `maxlength` attribute to set on the custom choice
931          * `<input>` element. Note that this a legacy property that exists for
932          * compatibility reasons. It is usually better to `maxlength(N)` validation
933          * expression. Only applicable when `create` is `true`.
934          */
935         __init__: function(value, choices, options) {
936                 if (typeof(choices) != 'object')
937                         choices = {};
938
939                 if (!Array.isArray(value))
940                         this.values = (value != null && value != '') ? [ value ] : [];
941                 else
942                         this.values = value;
943
944                 this.choices = choices;
945                 this.options = Object.assign({
946                         sort:               true,
947                         multiple:           Array.isArray(value),
948                         optional:           true,
949                         select_placeholder: _('-- Please choose --'),
950                         custom_placeholder: _('-- custom --'),
951                         display_items:      3,
952                         dropdown_items:     -1,
953                         create:             false,
954                         create_query:       '.create-item-input',
955                         create_template:    'script[type="item-template"]'
956                 }, options);
957         },
958
959         /** @override */
960         render: function() {
961                 var sb = E('div', {
962                         'id': this.options.id,
963                         'class': 'cbi-dropdown',
964                         'multiple': this.options.multiple ? '' : null,
965                         'optional': this.options.optional ? '' : null,
966                 }, E('ul'));
967
968                 var keys = Object.keys(this.choices);
969
970                 if (this.options.sort === true)
971                         keys.sort();
972                 else if (Array.isArray(this.options.sort))
973                         keys = this.options.sort;
974
975                 if (this.options.create)
976                         for (var i = 0; i < this.values.length; i++)
977                                 if (!this.choices.hasOwnProperty(this.values[i]))
978                                         keys.push(this.values[i]);
979
980                 for (var i = 0; i < keys.length; i++) {
981                         var label = this.choices[keys[i]];
982
983                         if (dom.elem(label))
984                                 label = label.cloneNode(true);
985
986                         sb.lastElementChild.appendChild(E('li', {
987                                 'data-value': keys[i],
988                                 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
989                         }, [ label || keys[i] ]));
990                 }
991
992                 if (this.options.create) {
993                         var createEl = E('input', {
994                                 'type': 'text',
995                                 'class': 'create-item-input',
996                                 'readonly': this.options.readonly ? '' : null,
997                                 'maxlength': this.options.maxlength,
998                                 'placeholder': this.options.custom_placeholder || this.options.placeholder
999                         });
1000
1001                         if (this.options.datatype || this.options.validate)
1002                                 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1003                                                           true, this.options.validate, 'blur', 'keyup');
1004
1005                         sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1006                 }
1007
1008                 if (this.options.create_markup)
1009                         sb.appendChild(E('script', { type: 'item-template' },
1010                                 this.options.create_markup));
1011
1012                 return this.bind(sb);
1013         },
1014
1015         /** @private */
1016         bind: function(sb) {
1017                 var o = this.options;
1018
1019                 o.multiple = sb.hasAttribute('multiple');
1020                 o.optional = sb.hasAttribute('optional');
1021                 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1022                 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1023                 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1024                 o.create_query = sb.getAttribute('item-create') || o.create_query;
1025                 o.create_template = sb.getAttribute('item-template') || o.create_template;
1026
1027                 var ul = sb.querySelector('ul'),
1028                     more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1029                     open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
1030                     canary = sb.appendChild(E('div')),
1031                     create = sb.querySelector(this.options.create_query),
1032                     ndisplay = this.options.display_items,
1033                     n = 0;
1034
1035                 if (this.options.multiple) {
1036                         var items = ul.querySelectorAll('li');
1037
1038                         for (var i = 0; i < items.length; i++) {
1039                                 this.transformItem(sb, items[i]);
1040
1041                                 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1042                                         items[i].setAttribute('display', n++);
1043                         }
1044                 }
1045                 else {
1046                         if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1047                                 var placeholder = E('li', { placeholder: '' },
1048                                         this.options.select_placeholder || this.options.placeholder);
1049
1050                                 ul.firstChild
1051                                         ? ul.insertBefore(placeholder, ul.firstChild)
1052                                         : ul.appendChild(placeholder);
1053                         }
1054
1055                         var items = ul.querySelectorAll('li'),
1056                             sel = sb.querySelectorAll('[selected]');
1057
1058                         sel.forEach(function(s) {
1059                                 s.removeAttribute('selected');
1060                         });
1061
1062                         var s = sel[0] || items[0];
1063                         if (s) {
1064                                 s.setAttribute('selected', '');
1065                                 s.setAttribute('display', n++);
1066                         }
1067
1068                         ndisplay--;
1069                 }
1070
1071                 this.saveValues(sb, ul);
1072
1073                 ul.setAttribute('tabindex', -1);
1074                 sb.setAttribute('tabindex', 0);
1075
1076                 if (ndisplay < 0)
1077                         sb.setAttribute('more', '')
1078                 else
1079                         sb.removeAttribute('more');
1080
1081                 if (ndisplay == this.options.display_items)
1082                         sb.setAttribute('empty', '')
1083                 else
1084                         sb.removeAttribute('empty');
1085
1086                 dom.content(more, (ndisplay == this.options.display_items)
1087                         ? (this.options.select_placeholder || this.options.placeholder) : '···');
1088
1089
1090                 sb.addEventListener('click', this.handleClick.bind(this));
1091                 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1092                 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1093                 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1094
1095                 if ('ontouchstart' in window) {
1096                         sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1097                         window.addEventListener('touchstart', this.closeAllDropdowns);
1098                 }
1099                 else {
1100                         sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1101                         sb.addEventListener('focus', this.handleFocus.bind(this));
1102
1103                         canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1104
1105                         window.addEventListener('mouseover', this.setFocus);
1106                         window.addEventListener('click', this.closeAllDropdowns);
1107                 }
1108
1109                 if (create) {
1110                         create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1111                         create.addEventListener('focus', this.handleCreateFocus.bind(this));
1112                         create.addEventListener('blur', this.handleCreateBlur.bind(this));
1113
1114                         var li = findParent(create, 'li');
1115
1116                         li.setAttribute('unselectable', '');
1117                         li.addEventListener('click', this.handleCreateClick.bind(this));
1118                 }
1119
1120                 this.node = sb;
1121
1122                 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1123                 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1124
1125                 dom.bindClassInstance(sb, this);
1126
1127                 return sb;
1128         },
1129
1130         /** @private */
1131         openDropdown: function(sb) {
1132                 var st = window.getComputedStyle(sb, null),
1133                     ul = sb.querySelector('ul'),
1134                     li = ul.querySelectorAll('li'),
1135                     fl = findParent(sb, '.cbi-value-field'),
1136                     sel = ul.querySelector('[selected]'),
1137                     rect = sb.getBoundingClientRect(),
1138                     items = Math.min(this.options.dropdown_items, li.length);
1139
1140                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1141                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1142                 });
1143
1144                 sb.setAttribute('open', '');
1145
1146                 var pv = ul.cloneNode(true);
1147                     pv.classList.add('preview');
1148
1149                 if (fl)
1150                         fl.classList.add('cbi-dropdown-open');
1151
1152                 if ('ontouchstart' in window) {
1153                         var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1154                             vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1155                             start = null;
1156
1157                         ul.style.top = sb.offsetHeight + 'px';
1158                         ul.style.left = -rect.left + 'px';
1159                         ul.style.right = (rect.right - vpWidth) + 'px';
1160                         ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1161                         ul.style.WebkitOverflowScrolling = 'touch';
1162
1163                         function getScrollParent(element) {
1164                                 var parent = element,
1165                                     style = getComputedStyle(element),
1166                                     excludeStaticParent = (style.position === 'absolute');
1167
1168                                 if (style.position === 'fixed')
1169                                         return document.body;
1170
1171                                 while ((parent = parent.parentElement) != null) {
1172                                         style = getComputedStyle(parent);
1173
1174                                         if (excludeStaticParent && style.position === 'static')
1175                                                 continue;
1176
1177                                         if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1178                                                 return parent;
1179                                 }
1180
1181                                 return document.body;
1182                         }
1183
1184                         var scrollParent = getScrollParent(sb),
1185                             scrollFrom = scrollParent.scrollTop,
1186                             scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1187
1188                         var scrollStep = function(timestamp) {
1189                                 if (!start) {
1190                                         start = timestamp;
1191                                         ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1192                                 }
1193
1194                                 var duration = Math.max(timestamp - start, 1);
1195                                 if (duration < 100) {
1196                                         scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1197                                         window.requestAnimationFrame(scrollStep);
1198                                 }
1199                                 else {
1200                                         scrollParent.scrollTop = scrollTo;
1201                                 }
1202                         };
1203
1204                         window.requestAnimationFrame(scrollStep);
1205                 }
1206                 else {
1207                         ul.style.maxHeight = '1px';
1208                         ul.style.top = ul.style.bottom = '';
1209
1210                         window.requestAnimationFrame(function() {
1211                                 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1212                                     fullHeight = 0,
1213                                     spaceAbove = rect.top,
1214                                     spaceBelow = window.innerHeight - rect.height - rect.top;
1215
1216                                 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1217                                         fullHeight += li[i].getBoundingClientRect().height;
1218
1219                                 if (fullHeight <= spaceBelow) {
1220                                         ul.style.top = rect.height + 'px';
1221                                         ul.style.maxHeight = spaceBelow + 'px';
1222                                 }
1223                                 else if (fullHeight <= spaceAbove) {
1224                                         ul.style.bottom = rect.height + 'px';
1225                                         ul.style.maxHeight = spaceAbove + 'px';
1226                                 }
1227                                 else if (spaceBelow >= spaceAbove) {
1228                                         ul.style.top = rect.height + 'px';
1229                                         ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1230                                 }
1231                                 else {
1232                                         ul.style.bottom = rect.height + 'px';
1233                                         ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1234                                 }
1235
1236                                 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1237                         });
1238                 }
1239
1240                 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1241                 for (var i = 0; i < cboxes.length; i++) {
1242                         cboxes[i].checked = true;
1243                         cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1244                 };
1245
1246                 ul.classList.add('dropdown');
1247
1248                 sb.insertBefore(pv, ul.nextElementSibling);
1249
1250                 li.forEach(function(l) {
1251                         l.setAttribute('tabindex', 0);
1252                 });
1253
1254                 sb.lastElementChild.setAttribute('tabindex', 0);
1255
1256                 this.setFocus(sb, sel || li[0], true);
1257         },
1258
1259         /** @private */
1260         closeDropdown: function(sb, no_focus) {
1261                 if (!sb.hasAttribute('open'))
1262                         return;
1263
1264                 var pv = sb.querySelector('ul.preview'),
1265                     ul = sb.querySelector('ul.dropdown'),
1266                     li = ul.querySelectorAll('li'),
1267                     fl = findParent(sb, '.cbi-value-field');
1268
1269                 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1270                 sb.lastElementChild.removeAttribute('tabindex');
1271
1272                 sb.removeChild(pv);
1273                 sb.removeAttribute('open');
1274                 sb.style.width = sb.style.height = '';
1275
1276                 ul.classList.remove('dropdown');
1277                 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1278
1279                 if (fl)
1280                         fl.classList.remove('cbi-dropdown-open');
1281
1282                 if (!no_focus)
1283                         this.setFocus(sb, sb);
1284
1285                 this.saveValues(sb, ul);
1286         },
1287
1288         /** @private */
1289         toggleItem: function(sb, li, force_state) {
1290                 if (li.hasAttribute('unselectable'))
1291                         return;
1292
1293                 if (this.options.multiple) {
1294                         var cbox = li.querySelector('input[type="checkbox"]'),
1295                             items = li.parentNode.querySelectorAll('li'),
1296                             label = sb.querySelector('ul.preview'),
1297                             sel = li.parentNode.querySelectorAll('[selected]').length,
1298                             more = sb.querySelector('.more'),
1299                             ndisplay = this.options.display_items,
1300                             n = 0;
1301
1302                         if (li.hasAttribute('selected')) {
1303                                 if (force_state !== true) {
1304                                         if (sel > 1 || this.options.optional) {
1305                                                 li.removeAttribute('selected');
1306                                                 cbox.checked = cbox.disabled = false;
1307                                                 sel--;
1308                                         }
1309                                         else {
1310                                                 cbox.disabled = true;
1311                                         }
1312                                 }
1313                         }
1314                         else {
1315                                 if (force_state !== false) {
1316                                         li.setAttribute('selected', '');
1317                                         cbox.checked = true;
1318                                         cbox.disabled = false;
1319                                         sel++;
1320                                 }
1321                         }
1322
1323                         while (label && label.firstElementChild)
1324                                 label.removeChild(label.firstElementChild);
1325
1326                         for (var i = 0; i < items.length; i++) {
1327                                 items[i].removeAttribute('display');
1328                                 if (items[i].hasAttribute('selected')) {
1329                                         if (ndisplay-- > 0) {
1330                                                 items[i].setAttribute('display', n++);
1331                                                 if (label)
1332                                                         label.appendChild(items[i].cloneNode(true));
1333                                         }
1334                                         var c = items[i].querySelector('input[type="checkbox"]');
1335                                         if (c)
1336                                                 c.disabled = (sel == 1 && !this.options.optional);
1337                                 }
1338                         }
1339
1340                         if (ndisplay < 0)
1341                                 sb.setAttribute('more', '');
1342                         else
1343                                 sb.removeAttribute('more');
1344
1345                         if (ndisplay === this.options.display_items)
1346                                 sb.setAttribute('empty', '');
1347                         else
1348                                 sb.removeAttribute('empty');
1349
1350                         dom.content(more, (ndisplay === this.options.display_items)
1351                                 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1352                 }
1353                 else {
1354                         var sel = li.parentNode.querySelector('[selected]');
1355                         if (sel) {
1356                                 sel.removeAttribute('display');
1357                                 sel.removeAttribute('selected');
1358                         }
1359
1360                         li.setAttribute('display', 0);
1361                         li.setAttribute('selected', '');
1362
1363                         this.closeDropdown(sb, true);
1364                 }
1365
1366                 this.saveValues(sb, li.parentNode);
1367         },
1368
1369         /** @private */
1370         transformItem: function(sb, li) {
1371                 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1372                     label = E('label');
1373
1374                 while (li.firstChild)
1375                         label.appendChild(li.firstChild);
1376
1377                 li.appendChild(cbox);
1378                 li.appendChild(label);
1379         },
1380
1381         /** @private */
1382         saveValues: function(sb, ul) {
1383                 var sel = ul.querySelectorAll('li[selected]'),
1384                     div = sb.lastElementChild,
1385                     name = this.options.name,
1386                     strval = '',
1387                     values = [];
1388
1389                 while (div.lastElementChild)
1390                         div.removeChild(div.lastElementChild);
1391
1392                 sel.forEach(function (s) {
1393                         if (s.hasAttribute('placeholder'))
1394                                 return;
1395
1396                         var v = {
1397                                 text: s.innerText,
1398                                 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1399                                 element: s
1400                         };
1401
1402                         div.appendChild(E('input', {
1403                                 type: 'hidden',
1404                                 name: name,
1405                                 value: v.value
1406                         }));
1407
1408                         values.push(v);
1409
1410                         strval += strval.length ? ' ' + v.value : v.value;
1411                 });
1412
1413                 var detail = {
1414                         instance: this,
1415                         element: sb
1416                 };
1417
1418                 if (this.options.multiple)
1419                         detail.values = values;
1420                 else
1421                         detail.value = values.length ? values[0] : null;
1422
1423                 sb.value = strval;
1424
1425                 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1426                         bubbles: true,
1427                         detail: detail
1428                 }));
1429         },
1430
1431         /** @private */
1432         setValues: function(sb, values) {
1433                 var ul = sb.querySelector('ul');
1434
1435                 if (this.options.create) {
1436                         for (var value in values) {
1437                                 this.createItems(sb, value);
1438
1439                                 if (!this.options.multiple)
1440                                         break;
1441                         }
1442                 }
1443
1444                 if (this.options.multiple) {
1445                         var lis = ul.querySelectorAll('li[data-value]');
1446                         for (var i = 0; i < lis.length; i++) {
1447                                 var value = lis[i].getAttribute('data-value');
1448                                 if (values === null || !(value in values))
1449                                         this.toggleItem(sb, lis[i], false);
1450                                 else
1451                                         this.toggleItem(sb, lis[i], true);
1452                         }
1453                 }
1454                 else {
1455                         var ph = ul.querySelector('li[placeholder]');
1456                         if (ph)
1457                                 this.toggleItem(sb, ph);
1458
1459                         var lis = ul.querySelectorAll('li[data-value]');
1460                         for (var i = 0; i < lis.length; i++) {
1461                                 var value = lis[i].getAttribute('data-value');
1462                                 if (values !== null && (value in values))
1463                                         this.toggleItem(sb, lis[i]);
1464                         }
1465                 }
1466         },
1467
1468         /** @private */
1469         setFocus: function(sb, elem, scroll) {
1470                 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1471                         return;
1472
1473                 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1474                         return;
1475
1476                 document.querySelectorAll('.focus').forEach(function(e) {
1477                         if (!matchesElem(e, 'input')) {
1478                                 e.classList.remove('focus');
1479                                 e.blur();
1480                         }
1481                 });
1482
1483                 if (elem) {
1484                         elem.focus();
1485                         elem.classList.add('focus');
1486
1487                         if (scroll)
1488                                 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1489                 }
1490         },
1491
1492         /** @private */
1493         createChoiceElement: function(sb, value, label) {
1494                 var tpl = sb.querySelector(this.options.create_template),
1495                     markup = null;
1496
1497                 if (tpl)
1498                         markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1499                 else
1500                         markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1501
1502                 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1503                     placeholder = new_item.querySelector('[data-label-placeholder]');
1504
1505                 if (placeholder) {
1506                         var content = E('span', {}, label || this.choices[value] || [ value ]);
1507
1508                         while (content.firstChild)
1509                                 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1510
1511                         placeholder.parentNode.removeChild(placeholder);
1512                 }
1513
1514                 if (this.options.multiple)
1515                         this.transformItem(sb, new_item);
1516
1517                 return new_item;
1518         },
1519
1520         /** @private */
1521         createItems: function(sb, value) {
1522                 var sbox = this,
1523                     val = (value || '').trim(),
1524                     ul = sb.querySelector('ul');
1525
1526                 if (!sbox.options.multiple)
1527                         val = val.length ? [ val ] : [];
1528                 else
1529                         val = val.length ? val.split(/\s+/) : [];
1530
1531                 val.forEach(function(item) {
1532                         var new_item = null;
1533
1534                         ul.childNodes.forEach(function(li) {
1535                                 if (li.getAttribute && li.getAttribute('data-value') === item)
1536                                         new_item = li;
1537                         });
1538
1539                         if (!new_item) {
1540                                 new_item = sbox.createChoiceElement(sb, item);
1541
1542                                 if (!sbox.options.multiple) {
1543                                         var old = ul.querySelector('li[created]');
1544                                         if (old)
1545                                                 ul.removeChild(old);
1546
1547                                         new_item.setAttribute('created', '');
1548                                 }
1549
1550                                 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1551                         }
1552
1553                         sbox.toggleItem(sb, new_item, true);
1554                         sbox.setFocus(sb, new_item, true);
1555                 });
1556         },
1557
1558         /**
1559          * Remove all existing choices from the dropdown menu.
1560          *
1561          * This function removes all preexisting dropdown choices from the widget,
1562          * keeping only choices currently being selected unless `reset_values` is
1563          * given, in which case all choices and deselected and removed.
1564          *
1565          * @instance
1566          * @memberof LuCI.ui.Dropdown
1567          * @param {boolean} [reset_value=false]
1568          * If set to `true`, deselect and remove selected choices as well instead
1569          * of keeping them.
1570          */
1571         clearChoices: function(reset_value) {
1572                 var ul = this.node.querySelector('ul'),
1573                     lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1574                     len = lis.length - (this.options.create ? 1 : 0),
1575                     val = reset_value ? null : this.getValue();
1576
1577                 for (var i = 0; i < len; i++) {
1578                         var lival = lis[i].getAttribute('data-value');
1579                         if (val == null ||
1580                                 (!this.options.multiple && val != lival) ||
1581                                 (this.options.multiple && val.indexOf(lival) == -1))
1582                                 ul.removeChild(lis[i]);
1583                 }
1584
1585                 if (reset_value)
1586                         this.setValues(this.node, {});
1587         },
1588
1589         /**
1590          * Add new choices to the dropdown menu.
1591          *
1592          * This function adds further choices to an existing dropdown menu,
1593          * ignoring choice values which are already present.
1594          *
1595          * @instance
1596          * @memberof LuCI.ui.Dropdown
1597          * @param {string[]} values
1598          * The choice values to add to the dropdown widget.
1599          *
1600          * @param {Object<string, *>} labels
1601          * The choice label values to use when adding dropdown choices. If no
1602          * label is found for a particular choice value, the value itself is used
1603          * as label text. Choice labels may be any valid value accepted by
1604          * {@link LuCI.dom#content}.
1605          */
1606         addChoices: function(values, labels) {
1607                 var sb = this.node,
1608                     ul = sb.querySelector('ul'),
1609                     lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1610
1611                 if (!Array.isArray(values))
1612                         values = L.toArray(values);
1613
1614                 if (!L.isObject(labels))
1615                         labels = {};
1616
1617                 for (var i = 0; i < values.length; i++) {
1618                         var found = false;
1619
1620                         for (var j = 0; j < lis.length; j++) {
1621                                 if (lis[j].getAttribute('data-value') === values[i]) {
1622                                         found = true;
1623                                         break;
1624                                 }
1625                         }
1626
1627                         if (found)
1628                                 continue;
1629
1630                         ul.insertBefore(
1631                                 this.createChoiceElement(sb, values[i], labels[values[i]]),
1632                                 ul.lastElementChild);
1633                 }
1634         },
1635
1636         /**
1637          * Close all open dropdown widgets in the current document.
1638          */
1639         closeAllDropdowns: function() {
1640                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1641                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1642                 });
1643         },
1644
1645         /** @private */
1646         handleClick: function(ev) {
1647                 var sb = ev.currentTarget;
1648
1649                 if (!sb.hasAttribute('open')) {
1650                         if (!matchesElem(ev.target, 'input'))
1651                                 this.openDropdown(sb);
1652                 }
1653                 else {
1654                         var li = findParent(ev.target, 'li');
1655                         if (li && li.parentNode.classList.contains('dropdown'))
1656                                 this.toggleItem(sb, li);
1657                         else if (li && li.parentNode.classList.contains('preview'))
1658                                 this.closeDropdown(sb);
1659                         else if (matchesElem(ev.target, 'span.open, span.more'))
1660                                 this.closeDropdown(sb);
1661                 }
1662
1663                 ev.preventDefault();
1664                 ev.stopPropagation();
1665         },
1666
1667         /** @private */
1668         handleKeydown: function(ev) {
1669                 var sb = ev.currentTarget;
1670
1671                 if (matchesElem(ev.target, 'input'))
1672                         return;
1673
1674                 if (!sb.hasAttribute('open')) {
1675                         switch (ev.keyCode) {
1676                         case 37:
1677                         case 38:
1678                         case 39:
1679                         case 40:
1680                                 this.openDropdown(sb);
1681                                 ev.preventDefault();
1682                         }
1683                 }
1684                 else {
1685                         var active = findParent(document.activeElement, 'li');
1686
1687                         switch (ev.keyCode) {
1688                         case 27:
1689                                 this.closeDropdown(sb);
1690                                 break;
1691
1692                         case 13:
1693                                 if (active) {
1694                                         if (!active.hasAttribute('selected'))
1695                                                 this.toggleItem(sb, active);
1696                                         this.closeDropdown(sb);
1697                                         ev.preventDefault();
1698                                 }
1699                                 break;
1700
1701                         case 32:
1702                                 if (active) {
1703                                         this.toggleItem(sb, active);
1704                                         ev.preventDefault();
1705                                 }
1706                                 break;
1707
1708                         case 38:
1709                                 if (active && active.previousElementSibling) {
1710                                         this.setFocus(sb, active.previousElementSibling);
1711                                         ev.preventDefault();
1712                                 }
1713                                 break;
1714
1715                         case 40:
1716                                 if (active && active.nextElementSibling) {
1717                                         this.setFocus(sb, active.nextElementSibling);
1718                                         ev.preventDefault();
1719                                 }
1720                                 break;
1721                         }
1722                 }
1723         },
1724
1725         /** @private */
1726         handleDropdownClose: function(ev) {
1727                 var sb = ev.currentTarget;
1728
1729                 this.closeDropdown(sb, true);
1730         },
1731
1732         /** @private */
1733         handleDropdownSelect: function(ev) {
1734                 var sb = ev.currentTarget,
1735                     li = findParent(ev.target, 'li');
1736
1737                 if (!li)
1738                         return;
1739
1740                 this.toggleItem(sb, li);
1741                 this.closeDropdown(sb, true);
1742         },
1743
1744         /** @private */
1745         handleMouseover: function(ev) {
1746                 var sb = ev.currentTarget;
1747
1748                 if (!sb.hasAttribute('open'))
1749                         return;
1750
1751                 var li = findParent(ev.target, 'li');
1752
1753                 if (li && li.parentNode.classList.contains('dropdown'))
1754                         this.setFocus(sb, li);
1755         },
1756
1757         /** @private */
1758         handleFocus: function(ev) {
1759                 var sb = ev.currentTarget;
1760
1761                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1762                         if (s !== sb || sb.hasAttribute('open'))
1763                                 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1764                 });
1765         },
1766
1767         /** @private */
1768         handleCanaryFocus: function(ev) {
1769                 this.closeDropdown(ev.currentTarget.parentNode);
1770         },
1771
1772         /** @private */
1773         handleCreateKeydown: function(ev) {
1774                 var input = ev.currentTarget,
1775                     sb = findParent(input, '.cbi-dropdown');
1776
1777                 switch (ev.keyCode) {
1778                 case 13:
1779                         ev.preventDefault();
1780
1781                         if (input.classList.contains('cbi-input-invalid'))
1782                                 return;
1783
1784                         this.createItems(sb, input.value);
1785                         input.value = '';
1786                         input.blur();
1787                         break;
1788                 }
1789         },
1790
1791         /** @private */
1792         handleCreateFocus: function(ev) {
1793                 var input = ev.currentTarget,
1794                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1795                     sb = findParent(input, '.cbi-dropdown');
1796
1797                 if (cbox)
1798                         cbox.checked = true;
1799
1800                 sb.setAttribute('locked-in', '');
1801         },
1802
1803         /** @private */
1804         handleCreateBlur: function(ev) {
1805                 var input = ev.currentTarget,
1806                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1807                     sb = findParent(input, '.cbi-dropdown');
1808
1809                 if (cbox)
1810                         cbox.checked = false;
1811
1812                 sb.removeAttribute('locked-in');
1813         },
1814
1815         /** @private */
1816         handleCreateClick: function(ev) {
1817                 ev.currentTarget.querySelector(this.options.create_query).focus();
1818         },
1819
1820         /** @override */
1821         setValue: function(values) {
1822                 if (this.options.multiple) {
1823                         if (!Array.isArray(values))
1824                                 values = (values != null && values != '') ? [ values ] : [];
1825
1826                         var v = {};
1827
1828                         for (var i = 0; i < values.length; i++)
1829                                 v[values[i]] = true;
1830
1831                         this.setValues(this.node, v);
1832                 }
1833                 else {
1834                         var v = {};
1835
1836                         if (values != null) {
1837                                 if (Array.isArray(values))
1838                                         v[values[0]] = true;
1839                                 else
1840                                         v[values] = true;
1841                         }
1842
1843                         this.setValues(this.node, v);
1844                 }
1845         },
1846
1847         /** @override */
1848         getValue: function() {
1849                 var div = this.node.lastElementChild,
1850                     h = div.querySelectorAll('input[type="hidden"]'),
1851                         v = [];
1852
1853                 for (var i = 0; i < h.length; i++)
1854                         v.push(h[i].value);
1855
1856                 return this.options.multiple ? v : v[0];
1857         }
1858 });
1859
1860 /**
1861  * Instantiate a rich dropdown choice widget allowing custom values.
1862  *
1863  * @constructor Combobox
1864  * @memberof LuCI.ui
1865  * @augments LuCI.ui.Dropdown
1866  *
1867  * @classdesc
1868  *
1869  * The `Combobox` class implements a rich, stylable dropdown menu which allows
1870  * to enter custom values. Historically, comboboxes used to be a dedicated
1871  * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1872  * with a set of enforced default properties for easier instantiation.
1873  *
1874  * UI widget instances are usually not supposed to be created by view code
1875  * directly, instead they're implicitely created by `LuCI.form` when
1876  * instantiating CBI forms.
1877  *
1878  * This class is automatically instantiated as part of `LuCI.ui`. To use it
1879  * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1880  * external JavaScript, use `L.require("ui").then(...)` and access the
1881  * `Combobox` property of the class instance value.
1882  *
1883  * @param {string|string[]} [value=null]
1884  * The initial input value(s).
1885  *
1886  * @param {Object<string, *>} choices
1887  * Object containing the selectable choices of the widget. The object keys
1888  * serve as values for the different choices while the values are used as
1889  * choice labels.
1890  *
1891  * @param {LuCI.ui.Combobox.InitOptions} [options]
1892  * Object describing the widget specific options to initialize the dropdown.
1893  */
1894 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1895         /**
1896          * Comboboxes support the same properties as
1897          * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1898          * specific values for the following properties:
1899          *
1900          * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1901          * @memberof LuCI.ui.Combobox
1902          *
1903          * @property {boolean} multiple=false
1904          * Since Comboboxes never allow selecting multiple values, this property
1905          * is forcibly set to `false`.
1906          *
1907          * @property {boolean} create=true
1908          * Since Comboboxes always allow custom choice values, this property is
1909          * forcibly set to `true`.
1910          *
1911          * @property {boolean} optional=true
1912          * Since Comboboxes are always optional, this property is forcibly set to
1913          * `true`.
1914          */
1915         __init__: function(value, choices, options) {
1916                 this.super('__init__', [ value, choices, Object.assign({
1917                         select_placeholder: _('-- Please choose --'),
1918                         custom_placeholder: _('-- custom --'),
1919                         dropdown_items: -1,
1920                         sort: true
1921                 }, options, {
1922                         multiple: false,
1923                         create: true,
1924                         optional: true
1925                 }) ]);
1926         }
1927 });
1928
1929 /**
1930  * Instantiate a combo button widget offering multiple action choices.
1931  *
1932  * @constructor ComboButton
1933  * @memberof LuCI.ui
1934  * @augments LuCI.ui.Dropdown
1935  *
1936  * @classdesc
1937  *
1938  * The `ComboButton` class implements a button element which can be expanded
1939  * into a dropdown to chose from a set of different action choices.
1940  *
1941  * UI widget instances are usually not supposed to be created by view code
1942  * directly, instead they're implicitely created by `LuCI.form` when
1943  * instantiating CBI forms.
1944  *
1945  * This class is automatically instantiated as part of `LuCI.ui`. To use it
1946  * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1947  * external JavaScript, use `L.require("ui").then(...)` and access the
1948  * `ComboButton` property of the class instance value.
1949  *
1950  * @param {string|string[]} [value=null]
1951  * The initial input value(s).
1952  *
1953  * @param {Object<string, *>} choices
1954  * Object containing the selectable choices of the widget. The object keys
1955  * serve as values for the different choices while the values are used as
1956  * choice labels.
1957  *
1958  * @param {LuCI.ui.ComboButton.InitOptions} [options]
1959  * Object describing the widget specific options to initialize the button.
1960  */
1961 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1962         /**
1963          * ComboButtons support the same properties as
1964          * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1965          * specific values for some properties and add aditional button specific
1966          * properties.
1967          *
1968          * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1969          * @memberof LuCI.ui.ComboButton
1970          *
1971          * @property {boolean} multiple=false
1972          * Since ComboButtons never allow selecting multiple actions, this property
1973          * is forcibly set to `false`.
1974          *
1975          * @property {boolean} create=false
1976          * Since ComboButtons never allow creating custom choices, this property
1977          * is forcibly set to `false`.
1978          *
1979          * @property {boolean} optional=false
1980          * Since ComboButtons must always select one action, this property is
1981          * forcibly set to `false`.
1982          *
1983          * @property {Object<string, string>} [classes]
1984          * Specifies a mapping of choice values to CSS class names. If an action
1985          * choice is selected by the user and if a corresponding entry exists in
1986          * the `classes` object, the class names corresponding to the selected
1987          * value are set on the button element.
1988          *
1989          * This is useful to apply different button styles, such as colors, to the
1990          * combined button depending on the selected action.
1991          *
1992          * @property {function} [click]
1993          * Specifies a handler function to invoke when the user clicks the button.
1994          * This function will be called with the button DOM node as `this` context
1995          * and receive the DOM click event as first as well as the selected action
1996          * choice value as second argument.
1997          */
1998         __init__: function(value, choices, options) {
1999                 this.super('__init__', [ value, choices, Object.assign({
2000                         sort: true
2001                 }, options, {
2002                         multiple: false,
2003                         create: false,
2004                         optional: false
2005                 }) ]);
2006         },
2007
2008         /** @override */
2009         render: function(/* ... */) {
2010                 var node = UIDropdown.prototype.render.apply(this, arguments),
2011                     val = this.getValue();
2012
2013                 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2014                         node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2015
2016                 return node;
2017         },
2018
2019         /** @private */
2020         handleClick: function(ev) {
2021                 var sb = ev.currentTarget,
2022                     t = ev.target;
2023
2024                 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2025                         return UIDropdown.prototype.handleClick.apply(this, arguments);
2026
2027                 if (this.options.click)
2028                         return this.options.click.call(sb, ev, this.getValue());
2029         },
2030
2031         /** @private */
2032         toggleItem: function(sb /*, ... */) {
2033                 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2034                     val = this.getValue();
2035
2036                 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2037                         sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2038                 else
2039                         sb.setAttribute('class', 'cbi-dropdown');
2040
2041                 return rv;
2042         }
2043 });
2044
2045 /**
2046  * Instantiate a dynamic list widget.
2047  *
2048  * @constructor DynamicList
2049  * @memberof LuCI.ui
2050  * @augments LuCI.ui.AbstractElement
2051  *
2052  * @classdesc
2053  *
2054  * The `DynamicList` class implements a widget which allows the user to specify
2055  * an arbitrary amount of input values, either from free formed text input or
2056  * from a set of predefined choices.
2057  *
2058  * UI widget instances are usually not supposed to be created by view code
2059  * directly, instead they're implicitely created by `LuCI.form` when
2060  * instantiating CBI forms.
2061  *
2062  * This class is automatically instantiated as part of `LuCI.ui`. To use it
2063  * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2064  * external JavaScript, use `L.require("ui").then(...)` and access the
2065  * `DynamicList` property of the class instance value.
2066  *
2067  * @param {string|string[]} [value=null]
2068  * The initial input value(s).
2069  *
2070  * @param {Object<string, *>} [choices]
2071  * Object containing the selectable choices of the widget. The object keys
2072  * serve as values for the different choices while the values are used as
2073  * choice labels. If omitted, no default choices are presented to the user,
2074  * instead a plain text input field is rendered allowing the user to add
2075  * arbitrary values to the dynamic list.
2076  *
2077  * @param {LuCI.ui.DynamicList.InitOptions} [options]
2078  * Object describing the widget specific options to initialize the dynamic list.
2079  */
2080 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2081         /**
2082          * In case choices are passed to the dynamic list contructor, the widget
2083          * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2084          * but enforces specific values for some dropdown properties.
2085          *
2086          * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2087          * @memberof LuCI.ui.DynamicList
2088          *
2089          * @property {boolean} multiple=false
2090          * Since dynamic lists never allow selecting multiple choices when adding
2091          * another list item, this property is forcibly set to `false`.
2092          *
2093          * @property {boolean} optional=true
2094          * Since dynamic lists use an embedded dropdown to present a list of
2095          * predefined choice values, the dropdown must be made optional to allow
2096          * it to remain unselected.
2097          */
2098         __init__: function(values, choices, options) {
2099                 if (!Array.isArray(values))
2100                         values = (values != null && values != '') ? [ values ] : [];
2101
2102                 if (typeof(choices) != 'object')
2103                         choices = null;
2104
2105                 this.values = values;
2106                 this.choices = choices;
2107                 this.options = Object.assign({}, options, {
2108                         multiple: false,
2109                         optional: true
2110                 });
2111         },
2112
2113         /** @override */
2114         render: function() {
2115                 var dl = E('div', {
2116                         'id': this.options.id,
2117                         'class': 'cbi-dynlist'
2118                 }, E('div', { 'class': 'add-item' }));
2119
2120                 if (this.choices) {
2121                         if (this.options.placeholder != null)
2122                                 this.options.select_placeholder = this.options.placeholder;
2123
2124                         var cbox = new UICombobox(null, this.choices, this.options);
2125
2126                         dl.lastElementChild.appendChild(cbox.render());
2127                 }
2128                 else {
2129                         var inputEl = E('input', {
2130                                 'id': this.options.id ? 'widget.' + this.options.id : null,
2131                                 'type': 'text',
2132                                 'class': 'cbi-input-text',
2133                                 'placeholder': this.options.placeholder
2134                         });
2135
2136                         dl.lastElementChild.appendChild(inputEl);
2137                         dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2138
2139                         if (this.options.datatype || this.options.validate)
2140                                 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2141                                                           true, this.options.validate, 'blur', 'keyup');
2142                 }
2143
2144                 for (var i = 0; i < this.values.length; i++) {
2145                         var label = this.choices ? this.choices[this.values[i]] : null;
2146
2147                         if (dom.elem(label))
2148                                 label = label.cloneNode(true);
2149
2150                         this.addItem(dl, this.values[i], label);
2151                 }
2152
2153                 return this.bind(dl);
2154         },
2155
2156         /** @private */
2157         bind: function(dl) {
2158                 dl.addEventListener('click', L.bind(this.handleClick, this));
2159                 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2160                 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2161
2162                 this.node = dl;
2163
2164                 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2165                 this.setChangeEvents(dl, 'cbi-dynlist-change');
2166
2167                 dom.bindClassInstance(dl, this);
2168
2169                 return dl;
2170         },
2171
2172         /** @private */
2173         addItem: function(dl, value, text, flash) {
2174                 var exists = false,
2175                     new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2176                                 E('span', {}, [ text || value ]),
2177                                 E('input', {
2178                                         'type': 'hidden',
2179                                         'name': this.options.name,
2180                                         'value': value })]);
2181
2182                 dl.querySelectorAll('.item').forEach(function(item) {
2183                         if (exists)
2184                                 return;
2185
2186                         var hidden = item.querySelector('input[type="hidden"]');
2187
2188                         if (hidden && hidden.parentNode !== item)
2189                                 hidden = null;
2190
2191                         if (hidden && hidden.value === value)
2192                                 exists = true;
2193                 });
2194
2195                 if (!exists) {
2196                         var ai = dl.querySelector('.add-item');
2197                         ai.parentNode.insertBefore(new_item, ai);
2198                 }
2199
2200                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2201                         bubbles: true,
2202                         detail: {
2203                                 instance: this,
2204                                 element: dl,
2205                                 value: value,
2206                                 add: true
2207                         }
2208                 }));
2209         },
2210
2211         /** @private */
2212         removeItem: function(dl, item) {
2213                 var value = item.querySelector('input[type="hidden"]').value;
2214                 var sb = dl.querySelector('.cbi-dropdown');
2215                 if (sb)
2216                         sb.querySelectorAll('ul > li').forEach(function(li) {
2217                                 if (li.getAttribute('data-value') === value) {
2218                                         if (li.hasAttribute('dynlistcustom'))
2219                                                 li.parentNode.removeChild(li);
2220                                         else
2221                                                 li.removeAttribute('unselectable');
2222                                 }
2223                         });
2224
2225                 item.parentNode.removeChild(item);
2226
2227                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2228                         bubbles: true,
2229                         detail: {
2230                                 instance: this,
2231                                 element: dl,
2232                                 value: value,
2233                                 remove: true
2234                         }
2235                 }));
2236         },
2237
2238         /** @private */
2239         handleClick: function(ev) {
2240                 var dl = ev.currentTarget,
2241                     item = findParent(ev.target, '.item');
2242
2243                 if (item) {
2244                         this.removeItem(dl, item);
2245                 }
2246                 else if (matchesElem(ev.target, '.cbi-button-add')) {
2247                         var input = ev.target.previousElementSibling;
2248                         if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2249                                 this.addItem(dl, input.value, null, true);
2250                                 input.value = '';
2251                         }
2252                 }
2253         },
2254
2255         /** @private */
2256         handleDropdownChange: function(ev) {
2257                 var dl = ev.currentTarget,
2258                     sbIn = ev.detail.instance,
2259                     sbEl = ev.detail.element,
2260                     sbVal = ev.detail.value;
2261
2262                 if (sbVal === null)
2263                         return;
2264
2265                 sbIn.setValues(sbEl, null);
2266                 sbVal.element.setAttribute('unselectable', '');
2267
2268                 if (sbVal.element.hasAttribute('created')) {
2269                         sbVal.element.removeAttribute('created');
2270                         sbVal.element.setAttribute('dynlistcustom', '');
2271                 }
2272
2273                 var label = sbVal.text;
2274
2275                 if (sbVal.element) {
2276                         label = E([]);
2277
2278                         for (var i = 0; i < sbVal.element.childNodes.length; i++)
2279                                 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2280                 }
2281
2282                 this.addItem(dl, sbVal.value, label, true);
2283         },
2284
2285         /** @private */
2286         handleKeydown: function(ev) {
2287                 var dl = ev.currentTarget,
2288                     item = findParent(ev.target, '.item');
2289
2290                 if (item) {
2291                         switch (ev.keyCode) {
2292                         case 8: /* backspace */
2293                                 if (item.previousElementSibling)
2294                                         item.previousElementSibling.focus();
2295
2296                                 this.removeItem(dl, item);
2297                                 break;
2298
2299                         case 46: /* delete */
2300                                 if (item.nextElementSibling) {
2301                                         if (item.nextElementSibling.classList.contains('item'))
2302                                                 item.nextElementSibling.focus();
2303                                         else
2304                                                 item.nextElementSibling.firstElementChild.focus();
2305                                 }
2306
2307                                 this.removeItem(dl, item);
2308                                 break;
2309                         }
2310                 }
2311                 else if (matchesElem(ev.target, '.cbi-input-text')) {
2312                         switch (ev.keyCode) {
2313                         case 13: /* enter */
2314                                 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2315                                         this.addItem(dl, ev.target.value, null, true);
2316                                         ev.target.value = '';
2317                                         ev.target.blur();
2318                                         ev.target.focus();
2319                                 }
2320
2321                                 ev.preventDefault();
2322                                 break;
2323                         }
2324                 }
2325         },
2326
2327         /** @override */
2328         getValue: function() {
2329                 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2330                     input = this.node.querySelector('.add-item > input[type="text"]'),
2331                     v = [];
2332
2333                 for (var i = 0; i < items.length; i++)
2334                         v.push(items[i].value);
2335
2336                 if (input && input.value != null && input.value.match(/\S/) &&
2337                     input.classList.contains('cbi-input-invalid') == false &&
2338                     v.filter(function(s) { return s == input.value }).length == 0)
2339                         v.push(input.value);
2340
2341                 return v;
2342         },
2343
2344         /** @override */
2345         setValue: function(values) {
2346                 if (!Array.isArray(values))
2347                         values = (values != null && values != '') ? [ values ] : [];
2348
2349                 var items = this.node.querySelectorAll('.item');
2350
2351                 for (var i = 0; i < items.length; i++)
2352                         if (items[i].parentNode === this.node)
2353                                 this.removeItem(this.node, items[i]);
2354
2355                 for (var i = 0; i < values.length; i++)
2356                         this.addItem(this.node, values[i],
2357                                 this.choices ? this.choices[values[i]] : null);
2358         },
2359
2360         /**
2361          * Add new suggested choices to the dynamic list.
2362          *
2363          * This function adds further choices to an existing dynamic list,
2364          * ignoring choice values which are already present.
2365          *
2366          * @instance
2367          * @memberof LuCI.ui.DynamicList
2368          * @param {string[]} values
2369          * The choice values to add to the dynamic lists suggestion dropdown.
2370          *
2371          * @param {Object<string, *>} labels
2372          * The choice label values to use when adding suggested choices. If no
2373          * label is found for a particular choice value, the value itself is used
2374          * as label text. Choice labels may be any valid value accepted by
2375          * {@link LuCI.dom#content}.
2376          */
2377         addChoices: function(values, labels) {
2378                 var dl = this.node.lastElementChild.firstElementChild;
2379                 dom.callClassMethod(dl, 'addChoices', values, labels);
2380         },
2381
2382         /**
2383          * Remove all existing choices from the dynamic list.
2384          *
2385          * This function removes all preexisting suggested choices from the widget.
2386          *
2387          * @instance
2388          * @memberof LuCI.ui.DynamicList
2389          */
2390         clearChoices: function() {
2391                 var dl = this.node.lastElementChild.firstElementChild;
2392                 dom.callClassMethod(dl, 'clearChoices');
2393         }
2394 });
2395
2396 /**
2397  * Instantiate a hidden input field widget.
2398  *
2399  * @constructor Hiddenfield
2400  * @memberof LuCI.ui
2401  * @augments LuCI.ui.AbstractElement
2402  *
2403  * @classdesc
2404  *
2405  * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2406  * which allows to store form data without exposing it to the user.
2407  *
2408  * UI widget instances are usually not supposed to be created by view code
2409  * directly, instead they're implicitely created by `LuCI.form` when
2410  * instantiating CBI forms.
2411  *
2412  * This class is automatically instantiated as part of `LuCI.ui`. To use it
2413  * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2414  * external JavaScript, use `L.require("ui").then(...)` and access the
2415  * `Hiddenfield` property of the class instance value.
2416  *
2417  * @param {string|string[]} [value=null]
2418  * The initial input value.
2419  *
2420  * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2421  * Object describing the widget specific options to initialize the hidden input.
2422  */
2423 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2424         __init__: function(value, options) {
2425                 this.value = value;
2426                 this.options = Object.assign({
2427
2428                 }, options);
2429         },
2430
2431         /** @override */
2432         render: function() {
2433                 var hiddenEl = E('input', {
2434                         'id': this.options.id,
2435                         'type': 'hidden',
2436                         'value': this.value
2437                 });
2438
2439                 return this.bind(hiddenEl);
2440         },
2441
2442         /** @private */
2443         bind: function(hiddenEl) {
2444                 this.node = hiddenEl;
2445
2446                 dom.bindClassInstance(hiddenEl, this);
2447
2448                 return hiddenEl;
2449         },
2450
2451         /** @override */
2452         getValue: function() {
2453                 return this.node.value;
2454         },
2455
2456         /** @override */
2457         setValue: function(value) {
2458                 this.node.value = value;
2459         }
2460 });
2461
2462 /**
2463  * Instantiate a file upload widget.
2464  *
2465  * @constructor FileUpload
2466  * @memberof LuCI.ui
2467  * @augments LuCI.ui.AbstractElement
2468  *
2469  * @classdesc
2470  *
2471  * The `FileUpload` class implements a widget which allows the user to upload,
2472  * browse, select and delete files beneath a predefined remote directory.
2473  *
2474  * UI widget instances are usually not supposed to be created by view code
2475  * directly, instead they're implicitely created by `LuCI.form` when
2476  * instantiating CBI forms.
2477  *
2478  * This class is automatically instantiated as part of `LuCI.ui`. To use it
2479  * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2480  * external JavaScript, use `L.require("ui").then(...)` and access the
2481  * `FileUpload` property of the class instance value.
2482  *
2483  * @param {string|string[]} [value=null]
2484  * The initial input value.
2485  *
2486  * @param {LuCI.ui.DynamicList.InitOptions} [options]
2487  * Object describing the widget specific options to initialize the file
2488  * upload control.
2489  */
2490 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2491         /**
2492          * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2493          * the following properties are recognized:
2494          *
2495          * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2496          * @memberof LuCI.ui.FileUpload
2497          *
2498          * @property {boolean} [show_hidden=false]
2499          * Specifies whether hidden files should be displayed when browsing remote
2500          * files. Note that this is not a security feature, hidden files are always
2501          * present in the remote file listings received, this option merely controls
2502          * whether they're displayed or not.
2503          *
2504          * @property {boolean} [enable_upload=true]
2505          * Specifies whether the widget allows the user to upload files. If set to
2506          * `false`, only existing files may be selected. Note that this is not a
2507          * security feature. Whether file upload requests are accepted remotely
2508          * depends on the ACL setup for the current session. This option merely
2509          * controls whether the upload controls are rendered or not.
2510          *
2511          * @property {boolean} [enable_remove=true]
2512          * Specifies whether the widget allows the user to delete remove files.
2513          * If set to `false`, existing files may not be removed. Note that this is
2514          * not a security feature. Whether file delete requests are accepted
2515          * remotely depends on the ACL setup for the current session. This option
2516          * merely controls whether the file remove controls are rendered or not.
2517          *
2518          * @property {string} [root_directory=/etc/luci-uploads]
2519          * Specifies the remote directory the upload and file browsing actions take
2520          * place in. Browsing to directories outside of the root directory is
2521          * prevented by the widget. Note that this is not a security feature.
2522          * Whether remote directories are browseable or not solely depends on the
2523          * ACL setup for the current session.
2524          */
2525         __init__: function(value, options) {
2526                 this.value = value;
2527                 this.options = Object.assign({
2528                         show_hidden: false,
2529                         enable_upload: true,
2530                         enable_remove: true,
2531                         root_directory: '/etc/luci-uploads'
2532                 }, options);
2533         },
2534
2535         /** @private */
2536         bind: function(browserEl) {
2537                 this.node = browserEl;
2538
2539                 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2540                 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2541
2542                 dom.bindClassInstance(browserEl, this);
2543
2544                 return browserEl;
2545         },
2546
2547         /** @override */
2548         render: function() {
2549                 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2550                         var label;
2551
2552                         if (L.isObject(stat) && stat.type != 'directory')
2553                                 this.stat = stat;
2554
2555                         if (this.stat != null)
2556                                 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2557                         else if (this.value != null)
2558                                 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2559                         else
2560                                 label = [ _('Select file…') ];
2561
2562                         return this.bind(E('div', { 'id': this.options.id }, [
2563                                 E('button', {
2564                                         'class': 'btn',
2565                                         'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser')
2566                                 }, label),
2567                                 E('div', {
2568                                         'class': 'cbi-filebrowser'
2569                                 }),
2570                                 E('input', {
2571                                         'type': 'hidden',
2572                                         'name': this.options.name,
2573                                         'value': this.value
2574                                 })
2575                         ]));
2576                 }, this));
2577         },
2578
2579         /** @private */
2580         truncatePath: function(path) {
2581                 if (path.length > 50)
2582                         path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2583
2584                 return path;
2585         },
2586
2587         /** @private */
2588         iconForType: function(type) {
2589                 switch (type) {
2590                 case 'symlink':
2591                         return E('img', {
2592                                 'src': L.resource('cbi/link.gif'),
2593                                 'title': _('Symbolic link'),
2594                                 'class': 'middle'
2595                         });
2596
2597                 case 'directory':
2598                         return E('img', {
2599                                 'src': L.resource('cbi/folder.gif'),
2600                                 'title': _('Directory'),
2601                                 'class': 'middle'
2602                         });
2603
2604                 default:
2605                         return E('img', {
2606                                 'src': L.resource('cbi/file.gif'),
2607                                 'title': _('File'),
2608                                 'class': 'middle'
2609                         });
2610                 }
2611         },
2612
2613         /** @private */
2614         canonicalizePath: function(path) {
2615                 return path.replace(/\/{2,}/, '/')
2616                         .replace(/\/\.(\/|$)/g, '/')
2617                         .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2618                         .replace(/\/$/, '');
2619         },
2620
2621         /** @private */
2622         splitPath: function(path) {
2623                 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2624                     cpath = this.canonicalizePath(path || '/');
2625
2626                 if (cpath.length <= croot.length)
2627                         return [ croot ];
2628
2629                 if (cpath.charAt(croot.length) != '/')
2630                         return [ croot ];
2631
2632                 var parts = cpath.substring(croot.length + 1).split(/\//);
2633
2634                 parts.unshift(croot);
2635
2636                 return parts;
2637         },
2638
2639         /** @private */
2640         handleUpload: function(path, list, ev) {
2641                 var form = ev.target.parentNode,
2642                     fileinput = form.querySelector('input[type="file"]'),
2643                     nameinput = form.querySelector('input[type="text"]'),
2644                     filename = (nameinput.value != null ? nameinput.value : '').trim();
2645
2646                 ev.preventDefault();
2647
2648                 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2649                         return;
2650
2651                 var existing = list.filter(function(e) { return e.name == filename })[0];
2652
2653                 if (existing != null && existing.type == 'directory')
2654                         return alert(_('A directory with the same name already exists.'));
2655                 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2656                         return;
2657
2658                 var data = new FormData();
2659
2660                 data.append('sessionid', L.env.sessionid);
2661                 data.append('filename', path + '/' + filename);
2662                 data.append('filedata', fileinput.files[0]);
2663
2664                 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2665                         progress: L.bind(function(btn, ev) {
2666                                 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2667                         }, this, ev.target)
2668                 }).then(L.bind(function(path, ev, res) {
2669                         var reply = res.json();
2670
2671                         if (L.isObject(reply) && reply.failure)
2672                                 alert(_('Upload request failed: %s').format(reply.message));
2673
2674                         return this.handleSelect(path, null, ev);
2675                 }, this, path, ev));
2676         },
2677
2678         /** @private */
2679         handleDelete: function(path, fileStat, ev) {
2680                 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2681                     name = path.replace(/^.+\//, ''),
2682                     msg;
2683
2684                 ev.preventDefault();
2685
2686                 if (fileStat.type == 'directory')
2687                         msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2688                 else
2689                         msg = _('Do you really want to delete "%s" ?').format(name);
2690
2691                 if (confirm(msg)) {
2692                         var button = this.node.firstElementChild,
2693                             hidden = this.node.lastElementChild;
2694
2695                         if (path == hidden.value) {
2696                                 dom.content(button, _('Select file…'));
2697                                 hidden.value = '';
2698                         }
2699
2700                         return fs.remove(path).then(L.bind(function(parent, ev) {
2701                                 return this.handleSelect(parent, null, ev);
2702                         }, this, parent, ev)).catch(function(err) {
2703                                 alert(_('Delete request failed: %s').format(err.message));
2704                         });
2705                 }
2706         },
2707
2708         /** @private */
2709         renderUpload: function(path, list) {
2710                 if (!this.options.enable_upload)
2711                         return E([]);
2712
2713                 return E([
2714                         E('a', {
2715                                 'href': '#',
2716                                 'class': 'btn cbi-button-positive',
2717                                 'click': function(ev) {
2718                                         var uploadForm = ev.target.nextElementSibling,
2719                                             fileInput = uploadForm.querySelector('input[type="file"]');
2720
2721                                         ev.target.style.display = 'none';
2722                                         uploadForm.style.display = '';
2723                                         fileInput.click();
2724                                 }
2725                         }, _('Upload file…')),
2726                         E('div', { 'class': 'upload', 'style': 'display:none' }, [
2727                                 E('input', {
2728                                         'type': 'file',
2729                                         'style': 'display:none',
2730                                         'change': function(ev) {
2731                                                 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2732                                                     uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2733
2734                                                 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2735                                                 uploadbtn.disabled = false;
2736                                         }
2737                                 }),
2738                                 E('button', {
2739                                         'class': 'btn',
2740                                         'click': function(ev) {
2741                                                 ev.preventDefault();
2742                                                 ev.target.previousElementSibling.click();
2743                                         }
2744                                 }, [ _('Browse…') ]),
2745                                 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2746                                 E('button', {
2747                                         'class': 'btn cbi-button-save',
2748                                         'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2749                                         'disabled': true
2750                                 }, [ _('Upload file') ])
2751                         ])
2752                 ]);
2753         },
2754
2755         /** @private */
2756         renderListing: function(container, path, list) {
2757                 var breadcrumb = E('p'),
2758                     rows = E('ul');
2759
2760                 list.sort(function(a, b) {
2761                         var isDirA = (a.type == 'directory'),
2762                             isDirB = (b.type == 'directory');
2763
2764                         if (isDirA != isDirB)
2765                                 return isDirA < isDirB;
2766
2767                         return a.name > b.name;
2768                 });
2769
2770                 for (var i = 0; i < list.length; i++) {
2771                         if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2772                                 continue;
2773
2774                         var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2775                             selected = (entrypath == this.node.lastElementChild.value),
2776                             mtime = new Date(list[i].mtime * 1000);
2777
2778                         rows.appendChild(E('li', [
2779                                 E('div', { 'class': 'name' }, [
2780                                         this.iconForType(list[i].type),
2781                                         ' ',
2782                                         E('a', {
2783                                                 'href': '#',
2784                                                 'style': selected ? 'font-weight:bold' : null,
2785                                                 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2786                                                         entrypath, list[i].type != 'directory' ? list[i] : null)
2787                                         }, '%h'.format(list[i].name))
2788                                 ]),
2789                                 E('div', { 'class': 'mtime hide-xs' }, [
2790                                         ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2791                                                 mtime.getFullYear(),
2792                                                 mtime.getMonth() + 1,
2793                                                 mtime.getDate(),
2794                                                 mtime.getHours(),
2795                                                 mtime.getMinutes(),
2796                                                 mtime.getSeconds())
2797                                 ]),
2798                                 E('div', [
2799                                         selected ? E('button', {
2800                                                 'class': 'btn',
2801                                                 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2802                                         }, [ _('Deselect') ]) : '',
2803                                         this.options.enable_remove ? E('button', {
2804                                                 'class': 'btn cbi-button-negative',
2805                                                 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2806                                         }, [ _('Delete') ]) : ''
2807                                 ])
2808                         ]));
2809                 }
2810
2811                 if (!rows.firstElementChild)
2812                         rows.appendChild(E('em', _('No entries in this directory')));
2813
2814                 var dirs = this.splitPath(path),
2815                     cur = '';
2816
2817                 for (var i = 0; i < dirs.length; i++) {
2818                         cur = cur ? cur + '/' + dirs[i] : dirs[i];
2819                         dom.append(breadcrumb, [
2820                                 i ? ' » ' : '',
2821                                 E('a', {
2822                                         'href': '#',
2823                                         'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2824                                 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2825                         ]);
2826                 }
2827
2828                 dom.content(container, [
2829                         breadcrumb,
2830                         rows,
2831                         E('div', { 'class': 'right' }, [
2832                                 this.renderUpload(path, list),
2833                                 E('a', {
2834                                         'href': '#',
2835                                         'class': 'btn',
2836                                         'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2837                                 }, _('Cancel'))
2838                         ]),
2839                 ]);
2840         },
2841
2842         /** @private */
2843         handleCancel: function(ev) {
2844                 var button = this.node.firstElementChild,
2845                     browser = button.nextElementSibling;
2846
2847                 browser.classList.remove('open');
2848                 button.style.display = '';
2849
2850                 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2851
2852                 ev.preventDefault();
2853         },
2854
2855         /** @private */
2856         handleReset: function(ev) {
2857                 var button = this.node.firstElementChild,
2858                     hidden = this.node.lastElementChild;
2859
2860                 hidden.value = '';
2861                 dom.content(button, _('Select file…'));
2862
2863                 this.handleCancel(ev);
2864         },
2865
2866         /** @private */
2867         handleSelect: function(path, fileStat, ev) {
2868                 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2869                     ul = browser.querySelector('ul');
2870
2871                 if (fileStat == null) {
2872                         dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2873                         L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2874                 }
2875                 else {
2876                         var button = this.node.firstElementChild,
2877                             hidden = this.node.lastElementChild;
2878
2879                         path = this.canonicalizePath(path);
2880
2881                         dom.content(button, [
2882                                 this.iconForType(fileStat.type),
2883                                 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2884                         ]);
2885
2886                         browser.classList.remove('open');
2887                         button.style.display = '';
2888                         hidden.value = path;
2889
2890                         this.stat = Object.assign({ path: path }, fileStat);
2891                         this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2892                 }
2893         },
2894
2895         /** @private */
2896         handleFileBrowser: function(ev) {
2897                 var button = ev.target,
2898                     browser = button.nextElementSibling,
2899                     path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2900
2901                 if (path.indexOf(this.options.root_directory) != 0)
2902                         path = this.options.root_directory;
2903
2904                 ev.preventDefault();
2905
2906                 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2907                         document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2908                                 dom.findClassInstance(browserEl).handleCancel(ev);
2909                         });
2910
2911                         button.style.display = 'none';
2912                         browser.classList.add('open');
2913
2914                         return this.renderListing(browser, path, list);
2915                 }, this, button, browser, path));
2916         },
2917
2918         /** @override */
2919         getValue: function() {
2920                 return this.node.lastElementChild.value;
2921         },
2922
2923         /** @override */
2924         setValue: function(value) {
2925                 this.node.lastElementChild.value = value;
2926         }
2927 });
2928
2929 /**
2930  * @class ui
2931  * @memberof LuCI
2932  * @hideconstructor
2933  * @classdesc
2934  *
2935  * Provides high level UI helper functionality.
2936  * To import the class in views, use `'require ui'`, to import it in
2937  * external JavaScript, use `L.require("ui").then(...)`.
2938  */
2939 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
2940         __init__: function() {
2941                 modalDiv = document.body.appendChild(
2942                         dom.create('div', { id: 'modal_overlay' },
2943                                 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
2944
2945                 tooltipDiv = document.body.appendChild(
2946                         dom.create('div', { class: 'cbi-tooltip' }));
2947
2948                 /* setup old aliases */
2949                 L.showModal = this.showModal;
2950                 L.hideModal = this.hideModal;
2951                 L.showTooltip = this.showTooltip;
2952                 L.hideTooltip = this.hideTooltip;
2953                 L.itemlist = this.itemlist;
2954
2955                 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
2956                 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
2957                 document.addEventListener('focus', this.showTooltip.bind(this), true);
2958                 document.addEventListener('blur', this.hideTooltip.bind(this), true);
2959
2960                 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
2961                 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
2962                 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
2963         },
2964
2965         /**
2966          * Display a modal overlay dialog with the specified contents.
2967          *
2968          * The modal overlay dialog covers the current view preventing interaction
2969          * with the underlying view contents. Only one modal dialog instance can
2970          * be opened. Invoking showModal() while a modal dialog is already open will
2971          * replace the open dialog with a new one having the specified contents.
2972          *
2973          * Additional CSS class names may be passed to influence the appearence of
2974          * the dialog. Valid values for the classes depend on the underlying theme.
2975          *
2976          * @see LuCI.dom.content
2977          *
2978          * @param {string} [title]
2979          * The title of the dialog. If `null`, no title element will be rendered.
2980          *
2981          * @param {*} contents
2982          * The contents to add to the modal dialog. This should be a DOM node or
2983          * a document fragment in most cases. The value is passed as-is to the
2984          * `dom.content()` function - refer to its documentation for applicable
2985          * values.
2986          *
2987          * @param {...string} [classes]
2988          * A number of extra CSS class names which are set on the modal dialog
2989          * element.
2990          *
2991          * @returns {Node}
2992          * Returns a DOM Node representing the modal dialog element.
2993          */
2994         showModal: function(title, children /* , ... */) {
2995                 var dlg = modalDiv.firstElementChild;
2996
2997                 dlg.setAttribute('class', 'modal');
2998
2999                 for (var i = 2; i < arguments.length; i++)
3000                         dlg.classList.add(arguments[i]);
3001
3002                 dom.content(dlg, dom.create('h4', {}, title));
3003                 dom.append(dlg, children);
3004
3005                 document.body.classList.add('modal-overlay-active');
3006
3007                 return dlg;
3008         },
3009
3010         /**
3011          * Close the open modal overlay dialog.
3012          *
3013          * This function will close an open modal dialog and restore the normal view
3014          * behaviour. It has no effect if no modal dialog is currently open.
3015          *
3016          * Note that this function is stand-alone, it does not rely on `this` and
3017          * will not invoke other class functions so it suitable to be used as event
3018          * handler as-is without the need to bind it first.
3019          */
3020         hideModal: function() {
3021                 document.body.classList.remove('modal-overlay-active');
3022         },
3023
3024         /** @private */
3025         showTooltip: function(ev) {
3026                 var target = findParent(ev.target, '[data-tooltip]');
3027
3028                 if (!target)
3029                         return;
3030
3031                 if (tooltipTimeout !== null) {
3032                         window.clearTimeout(tooltipTimeout);
3033                         tooltipTimeout = null;
3034                 }
3035
3036                 var rect = target.getBoundingClientRect(),
3037                     x = rect.left              + window.pageXOffset,
3038                     y = rect.top + rect.height + window.pageYOffset;
3039
3040                 tooltipDiv.className = 'cbi-tooltip';
3041                 tooltipDiv.innerHTML = '▲ ';
3042                 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3043
3044                 if (target.hasAttribute('data-tooltip-style'))
3045                         tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3046
3047                 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3048                         y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3049                         tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
3050                 }
3051
3052                 tooltipDiv.style.top = y + 'px';
3053                 tooltipDiv.style.left = x + 'px';
3054                 tooltipDiv.style.opacity = 1;
3055
3056                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3057                         bubbles: true,
3058                         detail: { target: target }
3059                 }));
3060         },
3061
3062         /** @private */
3063         hideTooltip: function(ev) {
3064                 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3065                     tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3066                         return;
3067
3068                 if (tooltipTimeout !== null) {
3069                         window.clearTimeout(tooltipTimeout);
3070                         tooltipTimeout = null;
3071                 }
3072
3073                 tooltipDiv.style.opacity = 0;
3074                 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3075
3076                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3077         },
3078
3079         /**
3080          * Add a notification banner at the top of the current view.
3081          *
3082          * A notification banner is an alert message usually displayed at the
3083          * top of the current view, spanning the entire availibe width.
3084          * Notification banners will stay in place until dismissed by the user.
3085          * Multiple banners may be shown at the same time.
3086          *
3087          * Additional CSS class names may be passed to influence the appearence of
3088          * the banner. Valid values for the classes depend on the underlying theme.
3089          *
3090          * @see LuCI.dom.content
3091          *
3092          * @param {string} [title]
3093          * The title of the notification banner. If `null`, no title element
3094          * will be rendered.
3095          *
3096          * @param {*} contents
3097          * The contents to add to the notification banner. This should be a DOM
3098          * node or a document fragment in most cases. The value is passed as-is
3099          * to the `dom.content()` function - refer to its documentation for
3100          * applicable values.
3101          *
3102          * @param {...string} [classes]
3103          * A number of extra CSS class names which are set on the notification
3104          * banner element.
3105          *
3106          * @returns {Node}
3107          * Returns a DOM Node representing the notification banner element.
3108          */
3109         addNotification: function(title, children /*, ... */) {
3110                 var mc = document.querySelector('#maincontent') || document.body;
3111                 var msg = E('div', {
3112                         'class': 'alert-message fade-in',
3113                         'style': 'display:flex',
3114                         'transitionend': function(ev) {
3115                                 var node = ev.currentTarget;
3116                                 if (node.parentNode && node.classList.contains('fade-out'))
3117                                         node.parentNode.removeChild(node);
3118                         }
3119                 }, [
3120                         E('div', { 'style': 'flex:10' }),
3121                         E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3122                                 E('button', {
3123                                         'class': 'btn',
3124                                         'style': 'margin-left:auto; margin-top:auto',
3125                                         'click': function(ev) {
3126                                                 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3127                                         },
3128
3129                                 }, [ _('Dismiss') ])
3130                         ])
3131                 ]);
3132
3133                 if (title != null)
3134                         dom.append(msg.firstElementChild, E('h4', {}, title));
3135
3136                 dom.append(msg.firstElementChild, children);
3137
3138                 for (var i = 2; i < arguments.length; i++)
3139                         msg.classList.add(arguments[i]);
3140
3141                 mc.insertBefore(msg, mc.firstElementChild);
3142
3143                 return msg;
3144         },
3145
3146         /**
3147          * Display or update an header area indicator.
3148          *
3149          * An indicator is a small label displayed in the header area of the screen
3150          * providing few amounts of status information such as item counts or state
3151          * toggle indicators.
3152          *
3153          * Multiple indicators may be shown at the same time and indicator labels
3154          * may be made clickable to display extended information or to initiate
3155          * further actions.
3156          *
3157          * Indicators can either use a default `active` or a less accented `inactive`
3158          * style which is useful for indicators representing state toggles.
3159          *
3160          * @param {string} id
3161          * The ID of the indicator. If an indicator with the given ID already exists,
3162          * it is updated with the given label and style.
3163          *
3164          * @param {string} label
3165          * The text to display in the indicator label.
3166          *
3167          * @param {function} [handler]
3168          * A handler function to invoke when the indicator label is clicked/touched
3169          * by the user. If omitted, the indicator is not clickable/touchable.
3170          *
3171          * Note that this parameter only applies to new indicators, when updating
3172          * existing labels it is ignored.
3173          *
3174          * @param {string} [style=active]
3175          * The indicator style to use. May be either `active` or `inactive`.
3176          *
3177          * @returns {boolean}
3178          * Returns `true` when the indicator has been updated or `false` when no
3179          * changes were made.
3180          */
3181         showIndicator: function(id, label, handler, style) {
3182                 if (indicatorDiv == null) {
3183                         indicatorDiv = document.body.querySelector('#indicators');
3184
3185                         if (indicatorDiv == null)
3186                                 return false;
3187                 }
3188
3189                 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3190                     indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) ||
3191                         indicatorDiv.appendChild(E('span', {
3192                                 'data-indicator': id,
3193                                 'data-clickable': handlerFn ? true : null,
3194                                 'click': handlerFn
3195                         }, ['']));
3196
3197                 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3198                         return false;
3199
3200                 indicatorElem.firstChild.data = label;
3201                 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3202                 return true;
3203         },
3204
3205         /**
3206          * Remove an header area indicator.
3207          *
3208          * This function removes the given indicator label from the header indicator
3209          * area. When the given indicator is not found, this function does nothing.
3210          *
3211          * @param {string} id
3212          * The ID of the indicator to remove.
3213          *
3214          * @returns {boolean}
3215          * Returns `true` when the indicator has been removed or `false` when the
3216          * requested indicator was not found.
3217          */
3218         hideIndicator: function(id) {
3219                 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3220
3221                 if (indicatorElem == null)
3222                         return false;
3223
3224                 indicatorDiv.removeChild(indicatorElem);
3225                 return true;
3226         },
3227
3228         /**
3229          * Formats a series of label/value pairs into list-like markup.
3230          *
3231          * This function transforms a flat array of alternating label and value
3232          * elements into a list-like markup, using the values in `separators` as
3233          * separators and appends the resulting nodes to the given parent DOM node.
3234          *
3235          * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3236          * `<strong>` element and the value corresponding to the label are
3237          * subsequently wrapped into a `<span class="nowrap">` element.
3238          *
3239          * The resulting `<span>` element tuples are joined by the given separators
3240          * to form the final markup which is appened to the given parent DOM node.
3241          *
3242          * @param {Node} node
3243          * The parent DOM node to append the markup to. Any previous child elements
3244          * will be removed.
3245          *
3246          * @param {Array<*>} items
3247          * An alternating array of labels and values. The label values will be
3248          * converted to plain strings, the values are used as-is and may be of
3249          * any type accepted by `LuCI.dom.content()`.
3250          *
3251          * @param {*|Array<*>} [separators=[E('br')]]
3252          * A single value or an array of separator values to separate each
3253          * label/value pair with. The function will cycle through the separators
3254          * when joining the pairs. If omitted, the default separator is a sole HTML
3255          * `<br>` element. Separator values are used as-is and may be of any type
3256          * accepted by `LuCI.dom.content()`.
3257          *
3258          * @returns {Node}
3259          * Returns the parent DOM node the formatted markup has been added to.
3260          */
3261         itemlist: function(node, items, separators) {
3262                 var children = [];
3263
3264                 if (!Array.isArray(separators))
3265                         separators = [ separators || E('br') ];
3266
3267                 for (var i = 0; i < items.length; i += 2) {
3268                         if (items[i+1] !== null && items[i+1] !== undefined) {
3269                                 var sep = separators[(i/2) % separators.length],
3270                                     cld = [];
3271
3272                                 children.push(E('span', { class: 'nowrap' }, [
3273                                         items[i] ? E('strong', items[i] + ': ') : '',
3274                                         items[i+1]
3275                                 ]));
3276
3277                                 if ((i+2) < items.length)
3278                                         children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3279                         }
3280                 }
3281
3282                 dom.content(node, children);
3283
3284                 return node;
3285         },
3286
3287         /**
3288          * @class
3289          * @memberof LuCI.ui
3290          * @hideconstructor
3291          * @classdesc
3292          *
3293          * The `tabs` class handles tab menu groups used throughout the view area.
3294          * It takes care of setting up tab groups, tracking their state and handling
3295          * related events.
3296          *
3297          * This class is automatically instantiated as part of `LuCI.ui`. To use it
3298          * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3299          * external JavaScript, use `L.require("ui").then(...)` and access the
3300          * `tabs` property of the class instance value.
3301          */
3302         tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3303                 /** @private */
3304                 init: function() {
3305                         var groups = [], prevGroup = null, currGroup = null;
3306
3307                         document.querySelectorAll('[data-tab]').forEach(function(tab) {
3308                                 var parent = tab.parentNode;
3309
3310                                 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3311                                         return;
3312
3313                                 if (!parent.hasAttribute('data-tab-group'))
3314                                         parent.setAttribute('data-tab-group', groups.length);
3315
3316                                 currGroup = +parent.getAttribute('data-tab-group');
3317
3318                                 if (currGroup !== prevGroup) {
3319                                         prevGroup = currGroup;
3320
3321                                         if (!groups[currGroup])
3322                                                 groups[currGroup] = [];
3323                                 }
3324
3325                                 groups[currGroup].push(tab);
3326                         });
3327
3328                         for (var i = 0; i < groups.length; i++)
3329                                 this.initTabGroup(groups[i]);
3330
3331                         document.addEventListener('dependency-update', this.updateTabs.bind(this));
3332
3333                         this.updateTabs();
3334                 },
3335
3336                 /**
3337                  * Initializes a new tab group from the given tab pane collection.
3338                  *
3339                  * This function cycles through the given tab pane DOM nodes, extracts
3340                  * their tab IDs, titles and active states, renders a corresponding
3341                  * tab menu and prepends it to the tab panes common parent DOM node.
3342                  *
3343                  * The tab menu labels will be set to the value of the `data-tab-title`
3344                  * attribute of each corresponding pane. The last pane with the
3345                  * `data-tab-active` attribute set to `true` will be selected by default.
3346                  *
3347                  * If no pane is marked as active, the first one will be preselected.
3348                  *
3349                  * @instance
3350                  * @memberof LuCI.ui.tabs
3351                  * @param {Array<Node>|NodeList} panes
3352                  * A collection of tab panes to build a tab group menu for. May be a
3353                  * plain array of DOM nodes or a NodeList collection, such as the result
3354                  * of a `querySelectorAll()` call or the `.childNodes` property of a
3355                  * DOM node.
3356                  */
3357                 initTabGroup: function(panes) {
3358                         if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3359                                 return;
3360
3361                         var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3362                             group = panes[0].parentNode,
3363                             groupId = +group.getAttribute('data-tab-group'),
3364                             selected = null;
3365
3366                         if (group.getAttribute('data-initialized') === 'true')
3367                                 return;
3368
3369                         for (var i = 0, pane; pane = panes[i]; i++) {
3370                                 var name = pane.getAttribute('data-tab'),
3371                                     title = pane.getAttribute('data-tab-title'),
3372                                     active = pane.getAttribute('data-tab-active') === 'true';
3373
3374                                 menu.appendChild(E('li', {
3375                                         'style': this.isEmptyPane(pane) ? 'display:none' : null,
3376                                         'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3377                                         'data-tab': name
3378                                 }, E('a', {
3379                                         'href': '#',
3380                                         'click': this.switchTab.bind(this)
3381                                 }, title)));
3382
3383                                 if (active)
3384                                         selected = i;
3385                         }
3386
3387                         group.parentNode.insertBefore(menu, group);
3388                         group.setAttribute('data-initialized', true);
3389
3390                         if (selected === null) {
3391                                 selected = this.getActiveTabId(panes[0]);
3392
3393                                 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3394                                         for (var i = 0; i < panes.length; i++) {
3395                                                 if (!this.isEmptyPane(panes[i])) {
3396                                                         selected = i;
3397                                                         break;
3398                                                 }
3399                                         }
3400                                 }
3401
3402                                 menu.childNodes[selected].classList.add('cbi-tab');
3403                                 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3404                                 panes[selected].setAttribute('data-tab-active', 'true');
3405
3406                                 this.setActiveTabId(panes[selected], selected);
3407                         }
3408
3409                         panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3410                                 detail: { tab: panes[selected].getAttribute('data-tab') }
3411                         }));
3412
3413                         this.updateTabs(group);
3414                 },
3415
3416                 /**
3417                  * Checks whether the given tab pane node is empty.
3418                  *
3419                  * @instance
3420                  * @memberof LuCI.ui.tabs
3421                  * @param {Node} pane
3422                  * The tab pane to check.
3423                  *
3424                  * @returns {boolean}
3425                  * Returns `true` if the pane is empty, else `false`.
3426                  */
3427                 isEmptyPane: function(pane) {
3428                         return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3429                 },
3430
3431                 /** @private */
3432                 getPathForPane: function(pane) {
3433                         var path = [], node = null;
3434
3435                         for (node = pane ? pane.parentNode : null;
3436                              node != null && node.hasAttribute != null;
3437                              node = node.parentNode)
3438                         {
3439                                 if (node.hasAttribute('data-tab'))
3440                                         path.unshift(node.getAttribute('data-tab'));
3441                                 else if (node.hasAttribute('data-section-id'))
3442                                         path.unshift(node.getAttribute('data-section-id'));
3443                         }
3444
3445                         return path.join('/');
3446                 },
3447
3448                 /** @private */
3449                 getActiveTabState: function() {
3450                         var page = document.body.getAttribute('data-page');
3451
3452                         try {
3453                                 var val = JSON.parse(window.sessionStorage.getItem('tab'));
3454                                 if (val.page === page && L.isObject(val.paths))
3455                                         return val;
3456                         }
3457                         catch(e) {}
3458
3459                         window.sessionStorage.removeItem('tab');
3460                         return { page: page, paths: {} };
3461                 },
3462
3463                 /** @private */
3464                 getActiveTabId: function(pane) {
3465                         var path = this.getPathForPane(pane);
3466                         return +this.getActiveTabState().paths[path] || 0;
3467                 },
3468
3469                 /** @private */
3470                 setActiveTabId: function(pane, tabIndex) {
3471                         var path = this.getPathForPane(pane);
3472
3473                         try {
3474                                 var state = this.getActiveTabState();
3475                                     state.paths[path] = tabIndex;
3476
3477                             window.sessionStorage.setItem('tab', JSON.stringify(state));
3478                         }
3479                         catch (e) { return false; }
3480
3481                         return true;
3482                 },
3483
3484                 /** @private */
3485                 updateTabs: function(ev, root) {
3486                         (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3487                                 var menu = pane.parentNode.previousElementSibling,
3488                                     tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3489                                     n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3490
3491                                 if (!menu || !tab)
3492                                         return;
3493
3494                                 if (this.isEmptyPane(pane)) {
3495                                         tab.style.display = 'none';
3496                                         tab.classList.remove('flash');
3497                                 }
3498                                 else if (tab.style.display === 'none') {
3499                                         tab.style.display = '';
3500                                         requestAnimationFrame(function() { tab.classList.add('flash') });
3501                                 }
3502
3503                                 if (n_errors) {
3504                                         tab.setAttribute('data-errors', n_errors);
3505                                         tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3506                                         tab.setAttribute('data-tooltip-style', 'error');
3507                                 }
3508                                 else {
3509                                         tab.removeAttribute('data-errors');
3510                                         tab.removeAttribute('data-tooltip');
3511                                 }
3512                         }, this));
3513                 },
3514
3515                 /** @private */
3516                 switchTab: function(ev) {
3517                         var tab = ev.target.parentNode,
3518                             name = tab.getAttribute('data-tab'),
3519                             menu = tab.parentNode,
3520                             group = menu.nextElementSibling,
3521                             groupId = +group.getAttribute('data-tab-group'),
3522                             index = 0;
3523
3524                         ev.preventDefault();
3525
3526                         if (!tab.classList.contains('cbi-tab-disabled'))
3527                                 return;
3528
3529                         menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3530                                 tab.classList.remove('cbi-tab');
3531                                 tab.classList.remove('cbi-tab-disabled');
3532                                 tab.classList.add(
3533                                         tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3534                         });
3535
3536                         group.childNodes.forEach(function(pane) {
3537                                 if (dom.matches(pane, '[data-tab]')) {
3538                                         if (pane.getAttribute('data-tab') === name) {
3539                                                 pane.setAttribute('data-tab-active', 'true');
3540                                                 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3541                                                 UI.prototype.tabs.setActiveTabId(pane, index);
3542                                         }
3543                                         else {
3544                                                 pane.setAttribute('data-tab-active', 'false');
3545                                         }
3546
3547                                         index++;
3548                                 }
3549                         });
3550                 }
3551         }),
3552
3553         /**
3554          * @typedef {Object} FileUploadReply
3555          * @memberof LuCI.ui
3556
3557          * @property {string} name - Name of the uploaded file without directory components
3558          * @property {number} size - Size of the uploaded file in bytes
3559          * @property {string} checksum - The MD5 checksum of the received file data
3560          * @property {string} sha256sum - The SHA256 checksum of the received file data
3561          */
3562
3563         /**
3564          * Display a modal file upload prompt.
3565          *
3566          * This function opens a modal dialog prompting the user to select and
3567          * upload a file to a predefined remote destination path.
3568          *
3569          * @param {string} path
3570          * The remote file path to upload the local file to.
3571          *
3572          * @param {Node} [progessStatusNode]
3573          * An optional DOM text node whose content text is set to the progress
3574          * percentage value during file upload.
3575          *
3576          * @returns {Promise<LuCI.ui.FileUploadReply>}
3577          * Returns a promise resolving to a file upload status object on success
3578          * or rejecting with an error in case the upload failed or has been
3579          * cancelled by the user.
3580          */
3581         uploadFile: function(path, progressStatusNode) {
3582                 return new Promise(function(resolveFn, rejectFn) {
3583                         UI.prototype.showModal(_('Uploading file…'), [
3584                                 E('p', _('Please select the file to upload.')),
3585                                 E('div', { 'style': 'display:flex' }, [
3586                                         E('div', { 'class': 'left', 'style': 'flex:1' }, [
3587                                                 E('input', {
3588                                                         type: 'file',
3589                                                         style: 'display:none',
3590                                                         change: function(ev) {
3591                                                                 var modal = dom.parent(ev.target, '.modal'),
3592                                                                     body = modal.querySelector('p'),
3593                                                                     upload = modal.querySelector('.cbi-button-action.important'),
3594                                                                     file = ev.currentTarget.files[0];
3595
3596                                                                 if (file == null)
3597                                                                         return;
3598
3599                                                                 dom.content(body, [
3600                                                                         E('ul', {}, [
3601                                                                                 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3602                                                                                 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3603                                                                         ])
3604                                                                 ]);
3605
3606                                                                 upload.disabled = false;
3607                                                                 upload.focus();
3608                                                         }
3609                                                 }),
3610                                                 E('button', {
3611                                                         'class': 'btn',
3612                                                         'click': function(ev) {
3613                                                                 ev.target.previousElementSibling.click();
3614                                                         }
3615                                                 }, [ _('Browse…') ])
3616                                         ]),
3617                                         E('div', { 'class': 'right', 'style': 'flex:1' }, [
3618                                                 E('button', {
3619                                                         'class': 'btn',
3620                                                         'click': function() {
3621                                                                 UI.prototype.hideModal();
3622                                                                 rejectFn(new Error('Upload has been cancelled'));
3623                                                         }
3624                                                 }, [ _('Cancel') ]),
3625                                                 ' ',
3626                                                 E('button', {
3627                                                         'class': 'btn cbi-button-action important',
3628                                                         'disabled': true,
3629                                                         'click': function(ev) {
3630                                                                 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3631
3632                                                                 if (!input.files[0])
3633                                                                         return;
3634
3635                                                                 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3636
3637                                                                 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3638
3639                                                                 var data = new FormData();
3640
3641                                                                 data.append('sessionid', rpc.getSessionID());
3642                                                                 data.append('filename', path);
3643                                                                 data.append('filedata', input.files[0]);
3644
3645                                                                 var filename = input.files[0].name;
3646
3647                                                                 request.post(L.env.cgi_base + '/cgi-upload', data, {
3648                                                                         timeout: 0,
3649                                                                         progress: function(pev) {
3650                                                                                 var percent = (pev.loaded / pev.total) * 100;
3651
3652                                                                                 if (progressStatusNode)
3653                                                                                         progressStatusNode.data = '%.2f%%'.format(percent);
3654
3655                                                                                 progress.setAttribute('title', '%.2f%%'.format(percent));
3656                                                                                 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3657                                                                         }
3658                                                                 }).then(function(res) {
3659                                                                         var reply = res.json();
3660
3661                                                                         UI.prototype.hideModal();
3662
3663                                                                         if (L.isObject(reply) && reply.failure) {
3664                                                                                 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3665                                                                                 rejectFn(new Error(reply.failure));
3666                                                                         }
3667                                                                         else {
3668                                                                                 reply.name = filename;
3669                                                                                 resolveFn(reply);
3670                                                                         }
3671                                                                 }, function(err) {
3672                                                                         UI.prototype.hideModal();
3673                                                                         rejectFn(err);
3674                                                                 });
3675                                                         }
3676                                                 }, [ _('Upload') ])
3677                                         ])
3678                                 ])
3679                         ]);
3680                 });
3681         },
3682
3683         /**
3684          * Perform a device connectivity test.
3685          *
3686          * Attempt to fetch a well known ressource from the remote device via HTTP
3687          * in order to test connectivity. This function is mainly useful to wait
3688          * for the router to come back online after a reboot or reconfiguration.
3689          *
3690          * @param {string} [proto=http]
3691          * The protocol to use for fetching the resource. May be either `http`
3692          * (the default) or `https`.
3693          *
3694          * @param {string} [host=window.location.host]
3695          * Override the host address to probe. By default the current host as seen
3696          * in the address bar is probed.
3697          *
3698          * @returns {Promise<Event>}
3699          * Returns a promise resolving to a `load` event in case the device is
3700          * reachable or rejecting with an `error` event in case it is not reachable
3701          * or rejecting with `null` when the connectivity check timed out.
3702          */
3703         pingDevice: function(proto, ipaddr) {
3704                 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3705
3706                 return new Promise(function(resolveFn, rejectFn) {
3707                         var img = new Image();
3708
3709                         img.onload = resolveFn;
3710                         img.onerror = rejectFn;
3711
3712                         window.setTimeout(rejectFn, 1000);
3713
3714                         img.src = target;
3715                 });
3716         },
3717
3718         /**
3719          * Wait for device to come back online and reconnect to it.
3720          *
3721          * Poll each given hostname or IP address and navigate to it as soon as
3722          * one of the addresses becomes reachable.
3723          *
3724          * @param {...string} [hosts=[window.location.host]]
3725          * The list of IP addresses and host names to check for reachability.
3726          * If omitted, the current value of `window.location.host` is used by
3727          * default.
3728          */
3729         awaitReconnect: function(/* ... */) {
3730                 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3731
3732                 window.setTimeout(L.bind(function() {
3733                         poll.add(L.bind(function() {
3734                                 var tasks = [], reachable = false;
3735
3736                                 for (var i = 0; i < 2; i++)
3737                                         for (var j = 0; j < ipaddrs.length; j++)
3738                                                 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3739                                                         .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3740
3741                                 return Promise.all(tasks).then(function() {
3742                                         if (reachable) {
3743                                                 poll.stop();
3744                                                 window.location = reachable;
3745                                         }
3746                                 });
3747                         }, this));
3748                 }, this), 5000);
3749         },
3750
3751         /**
3752          * @class
3753          * @memberof LuCI.ui
3754          * @hideconstructor
3755          * @classdesc
3756          *
3757          * The `changes` class encapsulates logic for visualizing, applying,
3758          * confirming and reverting staged UCI changesets.
3759          *
3760          * This class is automatically instantiated as part of `LuCI.ui`. To use it
3761          * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3762          * external JavaScript, use `L.require("ui").then(...)` and access the
3763          * `changes` property of the class instance value.
3764          */
3765         changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3766                 init: function() {
3767                         if (!L.env.sessionid)
3768                                 return;
3769
3770                         return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3771                 },
3772
3773                 /**
3774                  * Set the change count indicator.
3775                  *
3776                  * This function updates or hides the UCI change count indicator,
3777                  * depending on the passed change count. When the count is greater
3778                  * than 0, the change indicator is displayed or updated, otherwise it
3779                  * is removed.
3780                  *
3781                  * @instance
3782                  * @memberof LuCI.ui.changes
3783                  * @param {number} numChanges
3784                  * The number of changes to indicate.
3785                  */
3786                 setIndicator: function(n) {
3787                         var i = document.querySelector('.uci_change_indicator');
3788                         if (i == null) {
3789                                 var poll = document.getElementById('xhr_poll_status');
3790                                 i = poll.parentNode.insertBefore(E('a', {
3791                                         'href': '#',
3792                                         'class': 'uci_change_indicator label notice',
3793                                         'click': L.bind(this.displayChanges, this)
3794                                 }), poll);
3795                         }
3796
3797                         if (n > 0) {
3798                                 dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
3799                                 i.classList.add('flash');
3800                                 i.style.display = '';
3801                                 document.dispatchEvent(new CustomEvent('uci-new-changes'));
3802                         }
3803                         else {
3804                                 i.classList.remove('flash');
3805                                 i.style.display = 'none';
3806                                 document.dispatchEvent(new CustomEvent('uci-clear-changes'));
3807                         }
3808                 },
3809
3810                 /**
3811                  * Update the change count indicator.
3812                  *
3813                  * This function updates the UCI change count indicator from the given
3814                  * UCI changeset structure.
3815                  *
3816                  * @instance
3817                  * @memberof LuCI.ui.changes
3818                  * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3819                  * The UCI changeset to count.
3820                  */
3821                 renderChangeIndicator: function(changes) {
3822                         var n_changes = 0;
3823
3824                         for (var config in changes)
3825                                 if (changes.hasOwnProperty(config))
3826                                         n_changes += changes[config].length;
3827
3828                         this.changes = changes;
3829                         this.setIndicator(n_changes);
3830                 },
3831
3832                 /** @private */
3833                 changeTemplates: {
3834                         'add-3':      '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3835                         'set-3':      '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3836                         'set-4':      '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3837                         'remove-2':   '<del>uci del %0.<strong>%2</strong></del>',
3838                         'remove-3':   '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3839                         'order-3':    '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3840                         'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3841                         'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3842                         'rename-3':   '<var>uci rename %0.%2=<strong>%3</strong></var>',
3843                         'rename-4':   '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3844                 },
3845
3846                 /**
3847                  * Display the current changelog.
3848                  *
3849                  * Open a modal dialog visualizing the currently staged UCI changes
3850                  * and offer options to revert or apply the shown changes.
3851                  *
3852                  * @instance
3853                  * @memberof LuCI.ui.changes
3854                  */
3855                 displayChanges: function() {
3856                         var list = E('div', { 'class': 'uci-change-list' }),
3857                             dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3858                                 E('div', { 'class': 'cbi-section' }, [
3859                                         E('strong', _('Legend:')),
3860                                         E('div', { 'class': 'uci-change-legend' }, [
3861                                                 E('div', { 'class': 'uci-change-legend-label' }, [
3862                                                         E('ins', '&#160;'), ' ', _('Section added') ]),
3863                                                 E('div', { 'class': 'uci-change-legend-label' }, [
3864                                                         E('del', '&#160;'), ' ', _('Section removed') ]),
3865                                                 E('div', { 'class': 'uci-change-legend-label' }, [
3866                                                         E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
3867                                                 E('div', { 'class': 'uci-change-legend-label' }, [
3868                                                         E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
3869                                         E('br'), list,
3870                                         E('div', { 'class': 'right' }, [
3871                                                 E('button', {
3872                                                         'class': 'btn',
3873                                                         'click': UI.prototype.hideModal
3874                                                 }, [ _('Dismiss') ]), ' ',
3875                                                 E('button', {
3876                                                         'class': 'cbi-button cbi-button-positive important',
3877                                                         'click': L.bind(this.apply, this, true)
3878                                                 }, [ _('Save & Apply') ]), ' ',
3879                                                 E('button', {
3880                                                         'class': 'cbi-button cbi-button-reset',
3881                                                         'click': L.bind(this.revert, this)
3882                                                 }, [ _('Revert') ])])])
3883                         ]);
3884
3885                         for (var config in this.changes) {
3886                                 if (!this.changes.hasOwnProperty(config))
3887                                         continue;
3888
3889                                 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
3890
3891                                 for (var i = 0, added = null; i < this.changes[config].length; i++) {
3892                                         var chg = this.changes[config][i],
3893                                             tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
3894
3895                                         list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
3896                                                 switch (+m1) {
3897                                                 case 0:
3898                                                         return config;
3899
3900                                                 case 2:
3901                                                         if (added != null && chg[1] == added[0])
3902                                                                 return '@' + added[1] + '[-1]';
3903                                                         else
3904                                                                 return chg[1];
3905
3906                                                 case 4:
3907                                                         return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
3908
3909                                                 default:
3910                                                         return chg[m1-1];
3911                                                 }
3912                                         })));
3913
3914                                         if (chg[0] == 'add')
3915                                                 added = [ chg[1], chg[2] ];
3916                                 }
3917                         }
3918
3919                         list.appendChild(E('br'));
3920                         dlg.classList.add('uci-dialog');
3921                 },
3922
3923                 /** @private */
3924                 displayStatus: function(type, content) {
3925                         if (type) {
3926                                 var message = UI.prototype.showModal('', '');
3927
3928                                 message.classList.add('alert-message');
3929                                 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
3930
3931                                 if (content)
3932                                         dom.content(message, content);
3933
3934                                 if (!this.was_polling) {
3935                                         this.was_polling = request.poll.active();
3936                                         request.poll.stop();
3937                                 }
3938                         }
3939                         else {
3940                                 UI.prototype.hideModal();
3941
3942                                 if (this.was_polling)
3943                                         request.poll.start();
3944                         }
3945                 },
3946
3947                 /** @private */
3948                 rollback: function(checked) {
3949                         if (checked) {
3950                                 this.displayStatus('warning spinning',
3951                                         E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
3952                                                 .format(L.env.apply_rollback)));
3953
3954                                 var call = function(r, data, duration) {
3955                                         if (r.status === 204) {
3956                                                 UI.prototype.changes.displayStatus('warning', [
3957                                                         E('h4', _('Configuration changes have been rolled back!')),
3958                                                         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)),
3959                                                         E('div', { 'class': 'right' }, [
3960                                                                 E('button', {
3961                                                                         'class': 'btn',
3962                                                                         'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
3963                                                                 }, [ _('Dismiss') ]), ' ',
3964                                                                 E('button', {
3965                                                                         'class': 'btn cbi-button-action important',
3966                                                                         'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
3967                                                                 }, [ _('Revert changes') ]), ' ',
3968                                                                 E('button', {
3969                                                                         'class': 'btn cbi-button-negative important',
3970                                                                         'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
3971                                                                 }, [ _('Apply unchecked') ])
3972                                                         ])
3973                                                 ]);
3974
3975                                                 return;
3976                                         }
3977
3978                                         var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
3979                                         window.setTimeout(function() {
3980                                                 request.request(L.url('admin/uci/confirm'), {
3981                                                         method: 'post',
3982                                                         timeout: L.env.apply_timeout * 1000,
3983                                                         query: { sid: L.env.sessionid, token: L.env.token }
3984                                                 }).then(call);
3985                                         }, delay);
3986                                 };
3987
3988                                 call({ status: 0 });
3989                         }
3990                         else {
3991                                 this.displayStatus('warning', [
3992                                         E('h4', _('Device unreachable!')),
3993                                         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.'))
3994                                 ]);
3995                         }
3996                 },
3997
3998                 /** @private */
3999                 confirm: function(checked, deadline, override_token) {
4000                         var tt;
4001                         var ts = Date.now();
4002
4003                         this.displayStatus('notice');
4004
4005                         if (override_token)
4006                                 this.confirm_auth = { token: override_token };
4007
4008                         var call = function(r, data, duration) {
4009                                 if (Date.now() >= deadline) {
4010                                         window.clearTimeout(tt);
4011                                         UI.prototype.changes.rollback(checked);
4012                                         return;
4013                                 }
4014                                 else if (r && (r.status === 200 || r.status === 204)) {
4015                                         document.dispatchEvent(new CustomEvent('uci-applied'));
4016
4017                                         UI.prototype.changes.setIndicator(0);
4018                                         UI.prototype.changes.displayStatus('notice',
4019                                                 E('p', _('Configuration changes applied.')));
4020
4021                                         window.clearTimeout(tt);
4022                                         window.setTimeout(function() {
4023                                                 //UI.prototype.changes.displayStatus(false);
4024                                                 window.location = window.location.href.split('#')[0];
4025                                         }, L.env.apply_display * 1000);
4026
4027                                         return;
4028                                 }
4029
4030                                 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4031                                 window.setTimeout(function() {
4032                                         request.request(L.url('admin/uci/confirm'), {
4033                                                 method: 'post',
4034                                                 timeout: L.env.apply_timeout * 1000,
4035                                                 query: UI.prototype.changes.confirm_auth
4036                                         }).then(call, call);
4037                                 }, delay);
4038                         };
4039
4040                         var tick = function() {
4041                                 var now = Date.now();
4042
4043                                 UI.prototype.changes.displayStatus('notice spinning',
4044                                         E('p', _('Applying configuration changes… %ds')
4045                                                 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4046
4047                                 if (now >= deadline)
4048                                         return;
4049
4050                                 tt = window.setTimeout(tick, 1000 - (now - ts));
4051                                 ts = now;
4052                         };
4053
4054                         tick();
4055
4056                         /* wait a few seconds for the settings to become effective */
4057                         window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4058                 },
4059
4060                 /**
4061                  * Apply the staged configuration changes.
4062                  *
4063                  * Start applying staged configuration changes and open a modal dialog
4064                  * with a progress indication to prevent interaction with the view
4065                  * during the apply process. The modal dialog will be automatically
4066                  * closed and the current view reloaded once the apply process is
4067                  * complete.
4068                  *
4069                  * @instance
4070                  * @memberof LuCI.ui.changes
4071                  * @param {boolean} [checked=false]
4072                  * Whether to perform a checked (`true`) configuration apply or an
4073                  * unchecked (`false`) one.
4074
4075                  * In case of a checked apply, the configuration changes must be
4076                  * confirmed within a specific time interval, otherwise the device
4077                  * will begin to roll back the changes in order to restore the previous
4078                  * settings.
4079                  */
4080                 apply: function(checked) {
4081                         this.displayStatus('notice spinning',
4082                                 E('p', _('Starting configuration apply…')));
4083
4084                         request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4085                                 method: 'post',
4086                                 query: { sid: L.env.sessionid, token: L.env.token }
4087                         }).then(function(r) {
4088                                 if (r.status === (checked ? 200 : 204)) {
4089                                         var tok = null; try { tok = r.json(); } catch(e) {}
4090                                         if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4091                                                 UI.prototype.changes.confirm_auth = tok;
4092
4093                                         UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4094                                 }
4095                                 else if (checked && r.status === 204) {
4096                                         UI.prototype.changes.displayStatus('notice',
4097                                                 E('p', _('There are no changes to apply')));
4098
4099                                         window.setTimeout(function() {
4100                                                 UI.prototype.changes.displayStatus(false);
4101                                         }, L.env.apply_display * 1000);
4102                                 }
4103                                 else {
4104                                         UI.prototype.changes.displayStatus('warning',
4105                                                 E('p', _('Apply request failed with status <code>%h</code>')
4106                                                         .format(r.responseText || r.statusText || r.status)));
4107
4108                                         window.setTimeout(function() {
4109                                                 UI.prototype.changes.displayStatus(false);
4110                                         }, L.env.apply_display * 1000);
4111                                 }
4112                         });
4113                 },
4114
4115                 /**
4116                  * Revert the staged configuration changes.
4117                  *
4118                  * Start reverting staged configuration changes and open a modal dialog
4119                  * with a progress indication to prevent interaction with the view
4120                  * during the revert process. The modal dialog will be automatically
4121                  * closed and the current view reloaded once the revert process is
4122                  * complete.
4123                  *
4124                  * @instance
4125                  * @memberof LuCI.ui.changes
4126                  */
4127                 revert: function() {
4128                         this.displayStatus('notice spinning',
4129                                 E('p', _('Reverting configuration…')));
4130
4131                         request.request(L.url('admin/uci/revert'), {
4132                                 method: 'post',
4133                                 query: { sid: L.env.sessionid, token: L.env.token }
4134                         }).then(function(r) {
4135                                 if (r.status === 200) {
4136                                         document.dispatchEvent(new CustomEvent('uci-reverted'));
4137
4138                                         UI.prototype.changes.setIndicator(0);
4139                                         UI.prototype.changes.displayStatus('notice',
4140                                                 E('p', _('Changes have been reverted.')));
4141
4142                                         window.setTimeout(function() {
4143                                                 //UI.prototype.changes.displayStatus(false);
4144                                                 window.location = window.location.href.split('#')[0];
4145                                         }, L.env.apply_display * 1000);
4146                                 }
4147                                 else {
4148                                         UI.prototype.changes.displayStatus('warning',
4149                                                 E('p', _('Revert request failed with status <code>%h</code>')
4150                                                         .format(r.statusText || r.status)));
4151
4152                                         window.setTimeout(function() {
4153                                                 UI.prototype.changes.displayStatus(false);
4154                                         }, L.env.apply_display * 1000);
4155                                 }
4156                         });
4157                 }
4158         }),
4159
4160         /**
4161          * Add validation constraints to an input element.
4162          *
4163          * Compile the given type expression and optional validator function into
4164          * a validation function and bind it to the specified input element events.
4165          *
4166          * @param {Node} field
4167          * The DOM input element node to bind the validation constraints to.
4168          *
4169          * @param {string} type
4170          * The datatype specification to describe validation constraints.
4171          * Refer to the `LuCI.validation` class documentation for details.
4172          *
4173          * @param {boolean} [optional=false]
4174          * Specifies whether empty values are allowed (`true`) or not (`false`).
4175          * If an input element is not marked optional it must not be empty,
4176          * otherwise it will be marked as invalid.
4177          *
4178          * @param {function} [vfunc]
4179          * Specifies a custom validation function which is invoked after the
4180          * other validation constraints are applied. The validation must return
4181          * `true` to accept the passed value. Any other return type is converted
4182          * to a string and treated as validation error message.
4183          *
4184          * @param {...string} [events=blur, keyup]
4185          * The list of events to bind. Each received event will trigger a field
4186          * validation. If omitted, the `keyup` and `blur` events are bound by
4187          * default.
4188          *
4189          * @returns {function}
4190          * Returns the compiled validator function which can be used to manually
4191          * trigger field validation or to bind it to further events.
4192          *
4193          * @see LuCI.validation
4194          */
4195         addValidator: function(field, type, optional, vfunc /*, ... */) {
4196                 if (type == null)
4197                         return;
4198
4199                 var events = this.varargs(arguments, 3);
4200                 if (events.length == 0)
4201                         events.push('blur', 'keyup');
4202
4203                 try {
4204                         var cbiValidator = validation.create(field, type, optional, vfunc),
4205                             validatorFn = cbiValidator.validate.bind(cbiValidator);
4206
4207                         for (var i = 0; i < events.length; i++)
4208                                 field.addEventListener(events[i], validatorFn);
4209
4210                         validatorFn();
4211
4212                         return validatorFn;
4213                 }
4214                 catch (e) { }
4215         },
4216
4217         /**
4218          * Create a pre-bound event handler function.
4219          *
4220          * Generate and bind a function suitable for use in event handlers. The
4221          * generated function automatically disables the event source element
4222          * and adds an active indication to it by adding appropriate CSS classes.
4223          *
4224          * It will also await any promises returned by the wrapped function and
4225          * re-enable the source element after the promises ran to completion.
4226          *
4227          * @param {*} ctx
4228          * The `this` context to use for the wrapped function.
4229          *
4230          * @param {function|string} fn
4231          * Specifies the function to wrap. In case of a function value, the
4232          * function is used as-is. If a string is specified instead, it is looked
4233          * up in `ctx` to obtain the function to wrap. In both cases the bound
4234          * function will be invoked with `ctx` as `this` context
4235          *
4236          * @param {...*} extra_args
4237          * Any further parameter as passed as-is to the bound event handler
4238          * function in the same order as passed to `createHandlerFn()`.
4239          *
4240          * @returns {function|null}
4241          * Returns the pre-bound handler function which is suitable to be passed
4242          * to `addEventListener()`. Returns `null` if the given `fn` argument is
4243          * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4244          * valid function value.
4245          */
4246         createHandlerFn: function(ctx, fn /*, ... */) {
4247                 if (typeof(fn) == 'string')
4248                         fn = ctx[fn];
4249
4250                 if (typeof(fn) != 'function')
4251                         return null;
4252
4253                 var arg_offset = arguments.length - 2;
4254
4255                 return Function.prototype.bind.apply(function() {
4256                         var t = arguments[arg_offset].currentTarget;
4257
4258                         t.classList.add('spinning');
4259                         t.disabled = true;
4260
4261                         if (t.blur)
4262                                 t.blur();
4263
4264                         Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4265                                 t.classList.remove('spinning');
4266                                 t.disabled = false;
4267                         });
4268                 }, this.varargs(arguments, 2, ctx));
4269         },
4270
4271         /**
4272          * Load specified view class path and set it up.
4273          *
4274          * Transforms the given view path into a class name, requires it
4275          * using [LuCI.require()]{@link LuCI#require} and asserts that the
4276          * resulting class instance is a descendant of
4277          * [LuCI.view]{@link LuCI.view}.
4278          *
4279          * By instantiating the view class, its corresponding contents are
4280          * rendered and included into the view area. Any runtime errors are
4281          * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4282          *
4283          * @param {string} path
4284          * The view path to render.
4285          *
4286          * @returns {Promise<LuCI.view>}
4287          * Returns a promise resolving to the loaded view instance.
4288          */
4289         instantiateView: function(path) {
4290                 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4291
4292                 return L.require(className).then(function(view) {
4293                         if (!(view instanceof View))
4294                                 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4295
4296                         return view;
4297                 }).catch(function(err) {
4298                         dom.content(document.querySelector('#view'), null);
4299                         L.error(err);
4300                 });
4301         },
4302
4303         AbstractElement: UIElement,
4304
4305         /* Widgets */
4306         Textfield: UITextfield,
4307         Textarea: UITextarea,
4308         Checkbox: UICheckbox,
4309         Select: UISelect,
4310         Dropdown: UIDropdown,
4311         DynamicList: UIDynamicList,
4312         Combobox: UICombobox,
4313         ComboButton: UIComboButton,
4314         Hiddenfield: UIHiddenfield,
4315         FileUpload: UIFileUpload
4316 });
4317
4318 return UI;