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