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