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