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