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