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