15 tooltipTimeout = null;
18 * @class AbstractElement
23 * The `AbstractElement` class serves as abstract base for the different widgets
24 * implemented by `LuCI.ui`. It provides the common logic for getting and
25 * setting values, for checking the validity state and for wiring up required
28 * UI widget instances are usually not supposed to be created by view code
29 * directly, instead they're implicitely created by `LuCI.form` when
30 * instantiating CBI forms.
32 * This class is automatically instantiated as part of `LuCI.ui`. To use it
33 * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
34 * it in external JavaScript, use `L.require("ui").then(...)` and access the
35 * `AbstractElement` property of the class instance value.
37 var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
39 * @typedef {Object} InitOptions
40 * @memberof LuCI.ui.AbstractElement
42 * @property {string} [id]
43 * Specifies the widget ID to use. It will be used as HTML `id` attribute
44 * on the toplevel widget DOM node.
46 * @property {string} [name]
47 * Specifies the widget name which is set as HTML `name` attribute on the
48 * corresponding `<input>` element.
50 * @property {boolean} [optional=true]
51 * Specifies whether the input field allows empty values.
53 * @property {string} [datatype=string]
54 * An expression describing the input data validation constraints.
55 * It defaults to `string` which will allow any value.
56 * See {@link LuCI.validation} for details on the expression format.
58 * @property {function} [validator]
59 * Specifies a custom validator function which is invoked after the
60 * standard validation constraints are checked. The function should return
61 * `true` to accept the given input value. Any other return value type is
62 * converted to a string and treated as validation error message.
64 * @property {boolean} [disabled=false]
65 * Specifies whether the widget should be rendered in disabled state
66 * (`true`) or not (`false`). Disabled widgets cannot be interacted with
67 * and are displayed in a slightly faded style.
71 * Read the current value of the input widget.
74 * @memberof LuCI.ui.AbstractElement
75 * @returns {string|string[]|null}
76 * The current value of the input element. For simple inputs like text
77 * fields or selects, the return value type will be a - possibly empty -
78 * string. Complex widgets such as `DynamicList` instances may result in
79 * an array of strings or `null` for unset values.
81 getValue: function() {
82 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
83 return this.node.value;
89 * Set the current value of the input widget.
92 * @memberof LuCI.ui.AbstractElement
93 * @param {string|string[]|null} value
94 * The value to set the input element to. For simple inputs like text
95 * fields or selects, the value should be a - possibly empty - string.
96 * Complex widgets such as `DynamicList` instances may accept string array
99 setValue: function(value) {
100 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
101 this.node.value = value;
105 * Check whether the current input value is valid.
108 * @memberof LuCI.ui.AbstractElement
110 * Returns `true` if the current input value is valid or `false` if it does
111 * not meet the validation constraints.
113 isValid: function() {
114 return (this.validState !== false);
118 * Force validation of the current input value.
120 * Usually input validation is automatically triggered by various DOM events
121 * bound to the input widget. In some cases it is required though to manually
122 * trigger validation runs, e.g. when programmatically altering values.
125 * @memberof LuCI.ui.AbstractElement
127 triggerValidation: function() {
128 if (typeof(this.vfunc) != 'function')
131 var wasValid = this.isValid();
135 return (wasValid != this.isValid());
139 * Dispatch a custom (synthetic) event in response to received events.
141 * Sets up event handlers on the given target DOM node for the given event
142 * names that dispatch a custom event of the given type to the widget root
145 * The primary purpose of this function is to set up a series of custom
146 * uniform standard events such as `widget-update`, `validation-success`,
147 * `validation-failure` etc. which are triggered by various different
148 * widget specific native DOM events.
151 * @memberof LuCI.ui.AbstractElement
152 * @param {Node} targetNode
153 * Specifies the DOM node on which the native event listeners should be
156 * @param {string} synevent
157 * The name of the custom event to dispatch to the widget root DOM node.
159 * @param {string[]} events
160 * The native DOM events for which event handlers should be registered.
162 registerEvents: function(targetNode, synevent, events) {
163 var dispatchFn = L.bind(function(ev) {
164 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
167 for (var i = 0; i < events.length; i++)
168 targetNode.addEventListener(events[i], dispatchFn);
172 * Setup listeners for native DOM events that may update the widget value.
174 * Sets up event handlers on the given target DOM node for the given event
175 * names which may cause the input value to update, such as `keyup` or
176 * `onclick` events. In contrast to change events, such update events will
177 * trigger input value validation.
180 * @memberof LuCI.ui.AbstractElement
181 * @param {Node} targetNode
182 * Specifies the DOM node on which the event listeners should be registered.
184 * @param {...string} events
185 * The DOM events for which event handlers should be registered.
187 setUpdateEvents: function(targetNode /*, ... */) {
188 var datatype = this.options.datatype,
189 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
190 validate = this.options.validate,
191 events = this.varargs(arguments, 1);
193 this.registerEvents(targetNode, 'widget-update', events);
195 if (!datatype && !validate)
198 this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
199 targetNode, datatype || 'string',
203 this.node.addEventListener('validation-success', L.bind(function(ev) {
204 this.validState = true;
207 this.node.addEventListener('validation-failure', L.bind(function(ev) {
208 this.validState = false;
213 * Setup listeners for native DOM events that may change the widget value.
215 * Sets up event handlers on the given target DOM node for the given event
216 * names which may cause the input value to change completely, such as
217 * `change` events in a select menu. In contrast to update events, such
218 * change events will not trigger input value validation but they may cause
219 * field dependencies to get re-evaluated and will mark the input widget
223 * @memberof LuCI.ui.AbstractElement
224 * @param {Node} targetNode
225 * Specifies the DOM node on which the event listeners should be registered.
227 * @param {...string} events
228 * The DOM events for which event handlers should be registered.
230 setChangeEvents: function(targetNode /*, ... */) {
231 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
233 for (var i = 1; i < arguments.length; i++)
234 targetNode.addEventListener(arguments[i], tag_changed);
236 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
240 * Render the widget, setup event listeners and return resulting markup.
243 * @memberof LuCI.ui.AbstractElement
246 * Returns a DOM Node or DocumentFragment containing the rendered
249 render: function() {}
253 * Instantiate a text input widget.
255 * @constructor Textfield
257 * @augments LuCI.ui.AbstractElement
261 * The `Textfield` class implements a standard single line text input field.
263 * UI widget instances are usually not supposed to be created by view code
264 * directly, instead they're implicitely created by `LuCI.form` when
265 * instantiating CBI forms.
267 * This class is automatically instantiated as part of `LuCI.ui`. To use it
268 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
269 * external JavaScript, use `L.require("ui").then(...)` and access the
270 * `Textfield` property of the class instance value.
272 * @param {string} [value=null]
273 * The initial input value.
275 * @param {LuCI.ui.Textfield.InitOptions} [options]
276 * Object describing the widget specific options to initialize the input.
278 var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
280 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
281 * the following properties are recognized:
283 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
284 * @memberof LuCI.ui.Textfield
286 * @property {boolean} [password=false]
287 * Specifies whether the input should be rendered as concealed password field.
289 * @property {boolean} [readonly=false]
290 * Specifies whether the input widget should be rendered readonly.
292 * @property {number} [maxlength]
293 * Specifies the HTML `maxlength` attribute to set on the corresponding
294 * `<input>` element. Note that this a legacy property that exists for
295 * compatibility reasons. It is usually better to `maxlength(N)` validation
298 * @property {string} [placeholder]
299 * Specifies the HTML `placeholder` attribute which is displayed when the
300 * corresponding `<input>` element is empty.
302 __init__: function(value, options) {
304 this.options = Object.assign({
312 var frameEl = E('div', { 'id': this.options.id });
314 if (this.options.password) {
315 frameEl.classList.add('nowrap');
316 frameEl.appendChild(E('input', {
318 'style': 'position:absolute; left:-100000px',
321 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
325 frameEl.appendChild(E('input', {
326 'id': this.options.id ? 'widget.' + this.options.id : null,
327 'name': this.options.name,
328 'type': this.options.password ? 'password' : 'text',
329 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
330 'readonly': this.options.readonly ? '' : null,
331 'disabled': this.options.disabled ? '' : null,
332 'maxlength': this.options.maxlength,
333 'placeholder': this.options.placeholder,
337 if (this.options.password)
338 frameEl.appendChild(E('button', {
339 'class': 'cbi-button cbi-button-neutral',
340 'title': _('Reveal/hide password'),
341 'aria-label': _('Reveal/hide password'),
342 'click': function(ev) {
343 var e = this.previousElementSibling;
344 e.type = (e.type === 'password') ? 'text' : 'password';
349 return this.bind(frameEl);
353 bind: function(frameEl) {
354 var inputEl = frameEl.childNodes[+!!this.options.password];
358 this.setUpdateEvents(inputEl, 'keyup', 'blur');
359 this.setChangeEvents(inputEl, 'change');
361 dom.bindClassInstance(frameEl, this);
367 getValue: function() {
368 var inputEl = this.node.childNodes[+!!this.options.password];
369 return inputEl.value;
373 setValue: function(value) {
374 var inputEl = this.node.childNodes[+!!this.options.password];
375 inputEl.value = value;
380 * Instantiate a textarea widget.
382 * @constructor Textarea
384 * @augments LuCI.ui.AbstractElement
388 * The `Textarea` class implements a multiline text area input field.
390 * UI widget instances are usually not supposed to be created by view code
391 * directly, instead they're implicitely created by `LuCI.form` when
392 * instantiating CBI forms.
394 * This class is automatically instantiated as part of `LuCI.ui`. To use it
395 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
396 * external JavaScript, use `L.require("ui").then(...)` and access the
397 * `Textarea` property of the class instance value.
399 * @param {string} [value=null]
400 * The initial input value.
402 * @param {LuCI.ui.Textarea.InitOptions} [options]
403 * Object describing the widget specific options to initialize the input.
405 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
407 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
408 * the following properties are recognized:
410 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
411 * @memberof LuCI.ui.Textarea
413 * @property {boolean} [readonly=false]
414 * Specifies whether the input widget should be rendered readonly.
416 * @property {string} [placeholder]
417 * Specifies the HTML `placeholder` attribute which is displayed when the
418 * corresponding `<textarea>` element is empty.
420 * @property {boolean} [monospace=false]
421 * Specifies whether a monospace font should be forced for the textarea
424 * @property {number} [cols]
425 * Specifies the HTML `cols` attribute to set on the corresponding
426 * `<textarea>` element.
428 * @property {number} [rows]
429 * Specifies the HTML `rows` attribute to set on the corresponding
430 * `<textarea>` element.
432 * @property {boolean} [wrap=false]
433 * Specifies whether the HTML `wrap` attribute should be set.
435 __init__: function(value, options) {
437 this.options = Object.assign({
447 var style = !this.options.cols ? 'width:100%' : null,
448 frameEl = E('div', { 'id': this.options.id, 'style': style }),
449 value = (this.value != null) ? String(this.value) : '';
451 frameEl.appendChild(E('textarea', {
452 'id': this.options.id ? 'widget.' + this.options.id : null,
453 'name': this.options.name,
454 'class': 'cbi-input-textarea',
455 'readonly': this.options.readonly ? '' : null,
456 'disabled': this.options.disabled ? '' : null,
457 'placeholder': this.options.placeholder,
459 'cols': this.options.cols,
460 'rows': this.options.rows,
461 'wrap': this.options.wrap ? '' : null
464 if (this.options.monospace)
465 frameEl.firstElementChild.style.fontFamily = 'monospace';
467 return this.bind(frameEl);
471 bind: function(frameEl) {
472 var inputEl = frameEl.firstElementChild;
476 this.setUpdateEvents(inputEl, 'keyup', 'blur');
477 this.setChangeEvents(inputEl, 'change');
479 dom.bindClassInstance(frameEl, this);
485 getValue: function() {
486 return this.node.firstElementChild.value;
490 setValue: function(value) {
491 this.node.firstElementChild.value = value;
496 * Instantiate a checkbox widget.
498 * @constructor Checkbox
500 * @augments LuCI.ui.AbstractElement
504 * The `Checkbox` class implements a simple checkbox input field.
506 * UI widget instances are usually not supposed to be created by view code
507 * directly, instead they're implicitely created by `LuCI.form` when
508 * instantiating CBI forms.
510 * This class is automatically instantiated as part of `LuCI.ui`. To use it
511 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
512 * external JavaScript, use `L.require("ui").then(...)` and access the
513 * `Checkbox` property of the class instance value.
515 * @param {string} [value=null]
516 * The initial input value.
518 * @param {LuCI.ui.Checkbox.InitOptions} [options]
519 * Object describing the widget specific options to initialize the input.
521 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
523 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
524 * the following properties are recognized:
526 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
527 * @memberof LuCI.ui.Checkbox
529 * @property {string} [value_enabled=1]
530 * Specifies the value corresponding to a checked checkbox.
532 * @property {string} [value_disabled=0]
533 * Specifies the value corresponding to an unchecked checkbox.
535 * @property {string} [hiddenname]
536 * Specifies the HTML `name` attribute of the hidden input backing the
537 * checkbox. This is a legacy property existing for compatibility reasons,
538 * it is required for HTML based form submissions.
540 __init__: function(value, options) {
542 this.options = Object.assign({
550 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
551 var frameEl = E('div', {
552 'id': this.options.id,
553 'class': 'cbi-checkbox'
556 if (this.options.hiddenname)
557 frameEl.appendChild(E('input', {
559 'name': this.options.hiddenname,
563 frameEl.appendChild(E('input', {
565 'name': this.options.name,
567 'value': this.options.value_enabled,
568 'checked': (this.value == this.options.value_enabled) ? '' : null,
569 'disabled': this.options.disabled ? '' : null,
570 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
573 frameEl.appendChild(E('label', { 'for': id }));
575 return this.bind(frameEl);
579 bind: function(frameEl) {
582 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
583 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
585 dom.bindClassInstance(frameEl, this);
591 * Test whether the checkbox is currently checked.
594 * @memberof LuCI.ui.Checkbox
596 * Returns `true` when the checkbox is currently checked, otherwise `false`.
598 isChecked: function() {
599 return this.node.lastElementChild.previousElementSibling.checked;
603 getValue: function() {
604 return this.isChecked()
605 ? this.options.value_enabled
606 : this.options.value_disabled;
610 setValue: function(value) {
611 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
616 * Instantiate a select dropdown or checkbox/radiobutton group.
618 * @constructor Select
620 * @augments LuCI.ui.AbstractElement
624 * The `Select` class implements either a traditional HTML `<select>` element
625 * or a group of checkboxes or radio buttons, depending on whether multiple
626 * values are enabled or not.
628 * UI widget instances are usually not supposed to be created by view code
629 * directly, instead they're implicitely created by `LuCI.form` when
630 * instantiating CBI forms.
632 * This class is automatically instantiated as part of `LuCI.ui`. To use it
633 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
634 * external JavaScript, use `L.require("ui").then(...)` and access the
635 * `Select` property of the class instance value.
637 * @param {string|string[]} [value=null]
638 * The initial input value(s).
640 * @param {Object<string, string>} choices
641 * Object containing the selectable choices of the widget. The object keys
642 * serve as values for the different choices while the values are used as
645 * @param {LuCI.ui.Select.InitOptions} [options]
646 * Object describing the widget specific options to initialize the inputs.
648 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
650 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
651 * the following properties are recognized:
653 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
654 * @memberof LuCI.ui.Select
656 * @property {boolean} [multiple=false]
657 * Specifies whether multiple choice values may be selected.
659 * @property {string} [widget=select]
660 * Specifies the kind of widget to render. May be either `select` or
661 * `individual`. When set to `select` an HTML `<select>` element will be
662 * used, otherwise a group of checkbox or radio button elements is created,
663 * depending on the value of the `multiple` option.
665 * @property {string} [orientation=horizontal]
666 * Specifies whether checkbox / radio button groups should be rendered
667 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
670 * @property {boolean|string[]} [sort=false]
671 * Specifies if and how to sort choice values. If set to `true`, the choice
672 * values will be sorted alphabetically. If set to an array of strings, the
673 * choice sort order is derived from the array.
675 * @property {number} [size]
676 * Specifies the HTML `size` attribute to set on the `<select>` element.
677 * Only applicable to the `select` widget type.
679 * @property {string} [placeholder=-- Please choose --]
680 * Specifies a placeholder text which is displayed when no choice is
681 * selected yet. Only applicable to the `select` widget type.
683 __init__: function(value, choices, options) {
684 if (!L.isObject(choices))
687 if (!Array.isArray(value))
688 value = (value != null && value != '') ? [ value ] : [];
690 if (!options.multiple && value.length > 1)
694 this.choices = choices;
695 this.options = Object.assign({
698 orientation: 'horizontal'
701 if (this.choices.hasOwnProperty(''))
702 this.options.optional = true;
707 var frameEl = E('div', { 'id': this.options.id }),
708 keys = Object.keys(this.choices);
710 if (this.options.sort === true)
712 else if (Array.isArray(this.options.sort))
713 keys = this.options.sort;
715 if (this.options.widget == 'select') {
716 frameEl.appendChild(E('select', {
717 'id': this.options.id ? 'widget.' + this.options.id : null,
718 'name': this.options.name,
719 'size': this.options.size,
720 'class': 'cbi-input-select',
721 'multiple': this.options.multiple ? '' : null,
722 'disabled': this.options.disabled ? '' : null
725 if (this.options.optional)
726 frameEl.lastChild.appendChild(E('option', {
728 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
729 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
731 for (var i = 0; i < keys.length; i++) {
732 if (keys[i] == null || keys[i] == '')
735 frameEl.lastChild.appendChild(E('option', {
737 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
738 }, [ this.choices[keys[i]] || keys[i] ]));
742 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
744 for (var i = 0; i < keys.length; i++) {
745 frameEl.appendChild(E('label', {}, [
747 'id': this.options.id ? 'widget.' + this.options.id : null,
748 'name': this.options.id || this.options.name,
749 'type': this.options.multiple ? 'checkbox' : 'radio',
750 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
752 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
753 'disabled': this.options.disabled ? '' : null
755 this.choices[keys[i]] || keys[i]
758 if (i + 1 == this.options.size)
759 frameEl.appendChild(brEl);
763 return this.bind(frameEl);
767 bind: function(frameEl) {
770 if (this.options.widget == 'select') {
771 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
772 this.setChangeEvents(frameEl.firstChild, 'change');
775 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
776 for (var i = 0; i < radioEls.length; i++) {
777 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
778 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
782 dom.bindClassInstance(frameEl, this);
788 getValue: function() {
789 if (this.options.widget == 'select')
790 return this.node.firstChild.value;
792 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
793 for (var i = 0; i < radioEls.length; i++)
794 if (radioEls[i].checked)
795 return radioEls[i].value;
801 setValue: function(value) {
802 if (this.options.widget == 'select') {
806 for (var i = 0; i < this.node.firstChild.options.length; i++)
807 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
812 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
813 for (var i = 0; i < radioEls.length; i++)
814 radioEls[i].checked = (radioEls[i].value == value);
819 * Instantiate a rich dropdown choice widget.
821 * @constructor Dropdown
823 * @augments LuCI.ui.AbstractElement
827 * The `Dropdown` class implements a rich, stylable dropdown menu which
828 * supports non-text choice labels.
830 * UI widget instances are usually not supposed to be created by view code
831 * directly, instead they're implicitely created by `LuCI.form` when
832 * instantiating CBI forms.
834 * This class is automatically instantiated as part of `LuCI.ui`. To use it
835 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
836 * external JavaScript, use `L.require("ui").then(...)` and access the
837 * `Dropdown` property of the class instance value.
839 * @param {string|string[]} [value=null]
840 * The initial input value(s).
842 * @param {Object<string, *>} choices
843 * Object containing the selectable choices of the widget. The object keys
844 * serve as values for the different choices while the values are used as
847 * @param {LuCI.ui.Dropdown.InitOptions} [options]
848 * Object describing the widget specific options to initialize the dropdown.
850 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
852 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
853 * the following properties are recognized:
855 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
856 * @memberof LuCI.ui.Dropdown
858 * @property {boolean} [optional=true]
859 * Specifies whether the dropdown selection is optional. In contrast to
860 * other widgets, the `optional` constraint of dropdowns works differently;
861 * instead of marking the widget invalid on empty values when set to `false`,
862 * the user is not allowed to deselect all choices.
864 * For single value dropdowns that means that no empty "please select"
865 * choice is offered and for multi value dropdowns, the last selected choice
866 * may not be deselected without selecting another choice first.
868 * @property {boolean} [multiple]
869 * Specifies whether multiple choice values may be selected. It defaults
870 * to `true` when an array is passed as input value to the constructor.
872 * @property {boolean|string[]} [sort=false]
873 * Specifies if and how to sort choice values. If set to `true`, the choice
874 * values will be sorted alphabetically. If set to an array of strings, the
875 * choice sort order is derived from the array.
877 * @property {string} [select_placeholder=-- Please choose --]
878 * Specifies a placeholder text which is displayed when no choice is
881 * @property {string} [custom_placeholder=-- custom --]
882 * Specifies a placeholder text which is displayed in the text input
883 * field allowing to enter custom choice values. Only applicable if the
884 * `create` option is set to `true`.
886 * @property {boolean} [create=false]
887 * Specifies whether custom choices may be entered into the dropdown
890 * @property {string} [create_query=.create-item-input]
891 * Specifies a CSS selector expression used to find the input element
892 * which is used to enter custom choice values. This should not normally
893 * be used except by widgets derived from the Dropdown class.
895 * @property {string} [create_template=script[type="item-template"]]
896 * Specifies a CSS selector expression used to find an HTML element
897 * serving as template for newly added custom choice values.
899 * Any `{{value}}` placeholder string within the template elements text
900 * content will be replaced by the user supplied choice value, the
901 * resulting string is parsed as HTML and appended to the end of the
902 * choice list. The template markup may specify one HTML element with a
903 * `data-label-placeholder` attribute which is replaced by a matching
904 * label value from the `choices` object or with the user supplied value
905 * itself in case `choices` contains no matching choice label.
907 * If the template element is not found or if no `create_template` selector
908 * expression is specified, the default markup for newly created elements is
909 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
911 * @property {string} [create_markup]
912 * This property allows specifying the markup for custom choices directly
913 * instead of referring to a template element through CSS selectors.
915 * Apart from that it works exactly like `create_template`.
917 * @property {number} [display_items=3]
918 * Specifies the maximum amount of choice labels that should be shown in
919 * collapsed dropdown state before further selected choices are cut off.
921 * Only applicable when `multiple` is `true`.
923 * @property {number} [dropdown_items=-1]
924 * Specifies the maximum amount of choices that should be shown when the
925 * dropdown is open. If the amount of available choices exceeds this number,
926 * the dropdown area must be scrolled to reach further items.
928 * If set to `-1`, the dropdown menu will attempt to show all choice values
929 * and only resort to scrolling if the amount of choices exceeds the available
930 * screen space above and below the dropdown widget.
932 * @property {string} [placeholder]
933 * This property serves as a shortcut to set both `select_placeholder` and
934 * `custom_placeholder`. Either of these properties will fallback to
935 * `placeholder` if not specified.
937 * @property {boolean} [readonly=false]
938 * Specifies whether the custom choice input field should be rendered
939 * readonly. Only applicable when `create` is `true`.
941 * @property {number} [maxlength]
942 * Specifies the HTML `maxlength` attribute to set on the custom choice
943 * `<input>` element. Note that this a legacy property that exists for
944 * compatibility reasons. It is usually better to `maxlength(N)` validation
945 * expression. Only applicable when `create` is `true`.
947 __init__: function(value, choices, options) {
948 if (typeof(choices) != 'object')
951 if (!Array.isArray(value))
952 this.values = (value != null && value != '') ? [ value ] : [];
956 this.choices = choices;
957 this.options = Object.assign({
959 multiple: Array.isArray(value),
961 select_placeholder: _('-- Please choose --'),
962 custom_placeholder: _('-- custom --'),
966 create_query: '.create-item-input',
967 create_template: 'script[type="item-template"]'
974 'id': this.options.id,
975 'class': 'cbi-dropdown',
976 'multiple': this.options.multiple ? '' : null,
977 'optional': this.options.optional ? '' : null,
978 'disabled': this.options.disabled ? '' : null
981 var keys = Object.keys(this.choices);
983 if (this.options.sort === true)
985 else if (Array.isArray(this.options.sort))
986 keys = this.options.sort;
988 if (this.options.create)
989 for (var i = 0; i < this.values.length; i++)
990 if (!this.choices.hasOwnProperty(this.values[i]))
991 keys.push(this.values[i]);
993 for (var i = 0; i < keys.length; i++) {
994 var label = this.choices[keys[i]];
997 label = label.cloneNode(true);
999 sb.lastElementChild.appendChild(E('li', {
1000 'data-value': keys[i],
1001 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
1002 }, [ label || keys[i] ]));
1005 if (this.options.create) {
1006 var createEl = E('input', {
1008 'class': 'create-item-input',
1009 'readonly': this.options.readonly ? '' : null,
1010 'maxlength': this.options.maxlength,
1011 'placeholder': this.options.custom_placeholder || this.options.placeholder
1014 if (this.options.datatype || this.options.validate)
1015 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1016 true, this.options.validate, 'blur', 'keyup');
1018 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1021 if (this.options.create_markup)
1022 sb.appendChild(E('script', { type: 'item-template' },
1023 this.options.create_markup));
1025 return this.bind(sb);
1029 bind: function(sb) {
1030 var o = this.options;
1032 o.multiple = sb.hasAttribute('multiple');
1033 o.optional = sb.hasAttribute('optional');
1034 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1035 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1036 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1037 o.create_query = sb.getAttribute('item-create') || o.create_query;
1038 o.create_template = sb.getAttribute('item-template') || o.create_template;
1040 var ul = sb.querySelector('ul'),
1041 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1042 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, 'â–¾')),
1043 canary = sb.appendChild(E('div')),
1044 create = sb.querySelector(this.options.create_query),
1045 ndisplay = this.options.display_items,
1048 if (this.options.multiple) {
1049 var items = ul.querySelectorAll('li');
1051 for (var i = 0; i < items.length; i++) {
1052 this.transformItem(sb, items[i]);
1054 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1055 items[i].setAttribute('display', n++);
1059 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1060 var placeholder = E('li', { placeholder: '' },
1061 this.options.select_placeholder || this.options.placeholder);
1064 ? ul.insertBefore(placeholder, ul.firstChild)
1065 : ul.appendChild(placeholder);
1068 var items = ul.querySelectorAll('li'),
1069 sel = sb.querySelectorAll('[selected]');
1071 sel.forEach(function(s) {
1072 s.removeAttribute('selected');
1075 var s = sel[0] || items[0];
1077 s.setAttribute('selected', '');
1078 s.setAttribute('display', n++);
1084 this.saveValues(sb, ul);
1086 ul.setAttribute('tabindex', -1);
1087 sb.setAttribute('tabindex', 0);
1090 sb.setAttribute('more', '')
1092 sb.removeAttribute('more');
1094 if (ndisplay == this.options.display_items)
1095 sb.setAttribute('empty', '')
1097 sb.removeAttribute('empty');
1099 dom.content(more, (ndisplay == this.options.display_items)
1100 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1103 sb.addEventListener('click', this.handleClick.bind(this));
1104 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1105 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1106 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1108 if ('ontouchstart' in window) {
1109 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1110 window.addEventListener('touchstart', this.closeAllDropdowns);
1113 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1114 sb.addEventListener('focus', this.handleFocus.bind(this));
1116 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1118 window.addEventListener('mouseover', this.setFocus);
1119 window.addEventListener('click', this.closeAllDropdowns);
1123 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1124 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1125 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1127 var li = findParent(create, 'li');
1129 li.setAttribute('unselectable', '');
1130 li.addEventListener('click', this.handleCreateClick.bind(this));
1135 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1136 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1138 dom.bindClassInstance(sb, this);
1144 openDropdown: function(sb) {
1145 var st = window.getComputedStyle(sb, null),
1146 ul = sb.querySelector('ul'),
1147 li = ul.querySelectorAll('li'),
1148 fl = findParent(sb, '.cbi-value-field'),
1149 sel = ul.querySelector('[selected]'),
1150 rect = sb.getBoundingClientRect(),
1151 items = Math.min(this.options.dropdown_items, li.length);
1153 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1154 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1157 sb.setAttribute('open', '');
1159 var pv = ul.cloneNode(true);
1160 pv.classList.add('preview');
1163 fl.classList.add('cbi-dropdown-open');
1165 if ('ontouchstart' in window) {
1166 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1167 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1170 ul.style.top = sb.offsetHeight + 'px';
1171 ul.style.left = -rect.left + 'px';
1172 ul.style.right = (rect.right - vpWidth) + 'px';
1173 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1174 ul.style.WebkitOverflowScrolling = 'touch';
1176 function getScrollParent(element) {
1177 var parent = element,
1178 style = getComputedStyle(element),
1179 excludeStaticParent = (style.position === 'absolute');
1181 if (style.position === 'fixed')
1182 return document.body;
1184 while ((parent = parent.parentElement) != null) {
1185 style = getComputedStyle(parent);
1187 if (excludeStaticParent && style.position === 'static')
1190 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1194 return document.body;
1197 var scrollParent = getScrollParent(sb),
1198 scrollFrom = scrollParent.scrollTop,
1199 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1201 var scrollStep = function(timestamp) {
1204 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1207 var duration = Math.max(timestamp - start, 1);
1208 if (duration < 100) {
1209 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1210 window.requestAnimationFrame(scrollStep);
1213 scrollParent.scrollTop = scrollTo;
1217 window.requestAnimationFrame(scrollStep);
1220 ul.style.maxHeight = '1px';
1221 ul.style.top = ul.style.bottom = '';
1223 window.requestAnimationFrame(function() {
1224 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1226 spaceAbove = rect.top,
1227 spaceBelow = window.innerHeight - rect.height - rect.top;
1229 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1230 fullHeight += li[i].getBoundingClientRect().height;
1232 if (fullHeight <= spaceBelow) {
1233 ul.style.top = rect.height + 'px';
1234 ul.style.maxHeight = spaceBelow + 'px';
1236 else if (fullHeight <= spaceAbove) {
1237 ul.style.bottom = rect.height + 'px';
1238 ul.style.maxHeight = spaceAbove + 'px';
1240 else if (spaceBelow >= spaceAbove) {
1241 ul.style.top = rect.height + 'px';
1242 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1245 ul.style.bottom = rect.height + 'px';
1246 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1249 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1253 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1254 for (var i = 0; i < cboxes.length; i++) {
1255 cboxes[i].checked = true;
1256 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1259 ul.classList.add('dropdown');
1261 sb.insertBefore(pv, ul.nextElementSibling);
1263 li.forEach(function(l) {
1264 l.setAttribute('tabindex', 0);
1267 sb.lastElementChild.setAttribute('tabindex', 0);
1269 this.setFocus(sb, sel || li[0], true);
1273 closeDropdown: function(sb, no_focus) {
1274 if (!sb.hasAttribute('open'))
1277 var pv = sb.querySelector('ul.preview'),
1278 ul = sb.querySelector('ul.dropdown'),
1279 li = ul.querySelectorAll('li'),
1280 fl = findParent(sb, '.cbi-value-field');
1282 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1283 sb.lastElementChild.removeAttribute('tabindex');
1286 sb.removeAttribute('open');
1287 sb.style.width = sb.style.height = '';
1289 ul.classList.remove('dropdown');
1290 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1293 fl.classList.remove('cbi-dropdown-open');
1296 this.setFocus(sb, sb);
1298 this.saveValues(sb, ul);
1302 toggleItem: function(sb, li, force_state) {
1303 if (li.hasAttribute('unselectable'))
1306 if (this.options.multiple) {
1307 var cbox = li.querySelector('input[type="checkbox"]'),
1308 items = li.parentNode.querySelectorAll('li'),
1309 label = sb.querySelector('ul.preview'),
1310 sel = li.parentNode.querySelectorAll('[selected]').length,
1311 more = sb.querySelector('.more'),
1312 ndisplay = this.options.display_items,
1315 if (li.hasAttribute('selected')) {
1316 if (force_state !== true) {
1317 if (sel > 1 || this.options.optional) {
1318 li.removeAttribute('selected');
1319 cbox.checked = cbox.disabled = false;
1323 cbox.disabled = true;
1328 if (force_state !== false) {
1329 li.setAttribute('selected', '');
1330 cbox.checked = true;
1331 cbox.disabled = false;
1336 while (label && label.firstElementChild)
1337 label.removeChild(label.firstElementChild);
1339 for (var i = 0; i < items.length; i++) {
1340 items[i].removeAttribute('display');
1341 if (items[i].hasAttribute('selected')) {
1342 if (ndisplay-- > 0) {
1343 items[i].setAttribute('display', n++);
1345 label.appendChild(items[i].cloneNode(true));
1347 var c = items[i].querySelector('input[type="checkbox"]');
1349 c.disabled = (sel == 1 && !this.options.optional);
1354 sb.setAttribute('more', '');
1356 sb.removeAttribute('more');
1358 if (ndisplay === this.options.display_items)
1359 sb.setAttribute('empty', '');
1361 sb.removeAttribute('empty');
1363 dom.content(more, (ndisplay === this.options.display_items)
1364 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1367 var sel = li.parentNode.querySelector('[selected]');
1369 sel.removeAttribute('display');
1370 sel.removeAttribute('selected');
1373 li.setAttribute('display', 0);
1374 li.setAttribute('selected', '');
1376 this.closeDropdown(sb, true);
1379 this.saveValues(sb, li.parentNode);
1383 transformItem: function(sb, li) {
1384 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1387 while (li.firstChild)
1388 label.appendChild(li.firstChild);
1390 li.appendChild(cbox);
1391 li.appendChild(label);
1395 saveValues: function(sb, ul) {
1396 var sel = ul.querySelectorAll('li[selected]'),
1397 div = sb.lastElementChild,
1398 name = this.options.name,
1402 while (div.lastElementChild)
1403 div.removeChild(div.lastElementChild);
1405 sel.forEach(function (s) {
1406 if (s.hasAttribute('placeholder'))
1411 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1415 div.appendChild(E('input', {
1423 strval += strval.length ? ' ' + v.value : v.value;
1431 if (this.options.multiple)
1432 detail.values = values;
1434 detail.value = values.length ? values[0] : null;
1438 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1445 setValues: function(sb, values) {
1446 var ul = sb.querySelector('ul');
1448 if (this.options.create) {
1449 for (var value in values) {
1450 this.createItems(sb, value);
1452 if (!this.options.multiple)
1457 if (this.options.multiple) {
1458 var lis = ul.querySelectorAll('li[data-value]');
1459 for (var i = 0; i < lis.length; i++) {
1460 var value = lis[i].getAttribute('data-value');
1461 if (values === null || !(value in values))
1462 this.toggleItem(sb, lis[i], false);
1464 this.toggleItem(sb, lis[i], true);
1468 var ph = ul.querySelector('li[placeholder]');
1470 this.toggleItem(sb, ph);
1472 var lis = ul.querySelectorAll('li[data-value]');
1473 for (var i = 0; i < lis.length; i++) {
1474 var value = lis[i].getAttribute('data-value');
1475 if (values !== null && (value in values))
1476 this.toggleItem(sb, lis[i]);
1482 setFocus: function(sb, elem, scroll) {
1483 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1486 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1489 document.querySelectorAll('.focus').forEach(function(e) {
1490 if (!matchesElem(e, 'input')) {
1491 e.classList.remove('focus');
1498 elem.classList.add('focus');
1501 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1506 createChoiceElement: function(sb, value, label) {
1507 var tpl = sb.querySelector(this.options.create_template),
1511 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1513 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1515 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1516 placeholder = new_item.querySelector('[data-label-placeholder]');
1519 var content = E('span', {}, label || this.choices[value] || [ value ]);
1521 while (content.firstChild)
1522 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1524 placeholder.parentNode.removeChild(placeholder);
1527 if (this.options.multiple)
1528 this.transformItem(sb, new_item);
1534 createItems: function(sb, value) {
1536 val = (value || '').trim(),
1537 ul = sb.querySelector('ul');
1539 if (!sbox.options.multiple)
1540 val = val.length ? [ val ] : [];
1542 val = val.length ? val.split(/\s+/) : [];
1544 val.forEach(function(item) {
1545 var new_item = null;
1547 ul.childNodes.forEach(function(li) {
1548 if (li.getAttribute && li.getAttribute('data-value') === item)
1553 new_item = sbox.createChoiceElement(sb, item);
1555 if (!sbox.options.multiple) {
1556 var old = ul.querySelector('li[created]');
1558 ul.removeChild(old);
1560 new_item.setAttribute('created', '');
1563 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1566 sbox.toggleItem(sb, new_item, true);
1567 sbox.setFocus(sb, new_item, true);
1572 * Remove all existing choices from the dropdown menu.
1574 * This function removes all preexisting dropdown choices from the widget,
1575 * keeping only choices currently being selected unless `reset_values` is
1576 * given, in which case all choices and deselected and removed.
1579 * @memberof LuCI.ui.Dropdown
1580 * @param {boolean} [reset_value=false]
1581 * If set to `true`, deselect and remove selected choices as well instead
1584 clearChoices: function(reset_value) {
1585 var ul = this.node.querySelector('ul'),
1586 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1587 len = lis.length - (this.options.create ? 1 : 0),
1588 val = reset_value ? null : this.getValue();
1590 for (var i = 0; i < len; i++) {
1591 var lival = lis[i].getAttribute('data-value');
1593 (!this.options.multiple && val != lival) ||
1594 (this.options.multiple && val.indexOf(lival) == -1))
1595 ul.removeChild(lis[i]);
1599 this.setValues(this.node, {});
1603 * Add new choices to the dropdown menu.
1605 * This function adds further choices to an existing dropdown menu,
1606 * ignoring choice values which are already present.
1609 * @memberof LuCI.ui.Dropdown
1610 * @param {string[]} values
1611 * The choice values to add to the dropdown widget.
1613 * @param {Object<string, *>} labels
1614 * The choice label values to use when adding dropdown choices. If no
1615 * label is found for a particular choice value, the value itself is used
1616 * as label text. Choice labels may be any valid value accepted by
1617 * {@link LuCI.dom#content}.
1619 addChoices: function(values, labels) {
1621 ul = sb.querySelector('ul'),
1622 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1624 if (!Array.isArray(values))
1625 values = L.toArray(values);
1627 if (!L.isObject(labels))
1630 for (var i = 0; i < values.length; i++) {
1633 for (var j = 0; j < lis.length; j++) {
1634 if (lis[j].getAttribute('data-value') === values[i]) {
1644 this.createChoiceElement(sb, values[i], labels[values[i]]),
1645 ul.lastElementChild);
1650 * Close all open dropdown widgets in the current document.
1652 closeAllDropdowns: function() {
1653 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1654 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1659 handleClick: function(ev) {
1660 var sb = ev.currentTarget;
1662 if (!sb.hasAttribute('open')) {
1663 if (!matchesElem(ev.target, 'input'))
1664 this.openDropdown(sb);
1667 var li = findParent(ev.target, 'li');
1668 if (li && li.parentNode.classList.contains('dropdown'))
1669 this.toggleItem(sb, li);
1670 else if (li && li.parentNode.classList.contains('preview'))
1671 this.closeDropdown(sb);
1672 else if (matchesElem(ev.target, 'span.open, span.more'))
1673 this.closeDropdown(sb);
1676 ev.preventDefault();
1677 ev.stopPropagation();
1681 handleKeydown: function(ev) {
1682 var sb = ev.currentTarget;
1684 if (matchesElem(ev.target, 'input'))
1687 if (!sb.hasAttribute('open')) {
1688 switch (ev.keyCode) {
1693 this.openDropdown(sb);
1694 ev.preventDefault();
1698 var active = findParent(document.activeElement, 'li');
1700 switch (ev.keyCode) {
1702 this.closeDropdown(sb);
1707 if (!active.hasAttribute('selected'))
1708 this.toggleItem(sb, active);
1709 this.closeDropdown(sb);
1710 ev.preventDefault();
1716 this.toggleItem(sb, active);
1717 ev.preventDefault();
1722 if (active && active.previousElementSibling) {
1723 this.setFocus(sb, active.previousElementSibling);
1724 ev.preventDefault();
1729 if (active && active.nextElementSibling) {
1730 this.setFocus(sb, active.nextElementSibling);
1731 ev.preventDefault();
1739 handleDropdownClose: function(ev) {
1740 var sb = ev.currentTarget;
1742 this.closeDropdown(sb, true);
1746 handleDropdownSelect: function(ev) {
1747 var sb = ev.currentTarget,
1748 li = findParent(ev.target, 'li');
1753 this.toggleItem(sb, li);
1754 this.closeDropdown(sb, true);
1758 handleMouseover: function(ev) {
1759 var sb = ev.currentTarget;
1761 if (!sb.hasAttribute('open'))
1764 var li = findParent(ev.target, 'li');
1766 if (li && li.parentNode.classList.contains('dropdown'))
1767 this.setFocus(sb, li);
1771 handleFocus: function(ev) {
1772 var sb = ev.currentTarget;
1774 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1775 if (s !== sb || sb.hasAttribute('open'))
1776 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1781 handleCanaryFocus: function(ev) {
1782 this.closeDropdown(ev.currentTarget.parentNode);
1786 handleCreateKeydown: function(ev) {
1787 var input = ev.currentTarget,
1788 sb = findParent(input, '.cbi-dropdown');
1790 switch (ev.keyCode) {
1792 ev.preventDefault();
1794 if (input.classList.contains('cbi-input-invalid'))
1797 this.createItems(sb, input.value);
1805 handleCreateFocus: function(ev) {
1806 var input = ev.currentTarget,
1807 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1808 sb = findParent(input, '.cbi-dropdown');
1811 cbox.checked = true;
1813 sb.setAttribute('locked-in', '');
1817 handleCreateBlur: function(ev) {
1818 var input = ev.currentTarget,
1819 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1820 sb = findParent(input, '.cbi-dropdown');
1823 cbox.checked = false;
1825 sb.removeAttribute('locked-in');
1829 handleCreateClick: function(ev) {
1830 ev.currentTarget.querySelector(this.options.create_query).focus();
1834 setValue: function(values) {
1835 if (this.options.multiple) {
1836 if (!Array.isArray(values))
1837 values = (values != null && values != '') ? [ values ] : [];
1841 for (var i = 0; i < values.length; i++)
1842 v[values[i]] = true;
1844 this.setValues(this.node, v);
1849 if (values != null) {
1850 if (Array.isArray(values))
1851 v[values[0]] = true;
1856 this.setValues(this.node, v);
1861 getValue: function() {
1862 var div = this.node.lastElementChild,
1863 h = div.querySelectorAll('input[type="hidden"]'),
1866 for (var i = 0; i < h.length; i++)
1869 return this.options.multiple ? v : v[0];
1874 * Instantiate a rich dropdown choice widget allowing custom values.
1876 * @constructor Combobox
1878 * @augments LuCI.ui.Dropdown
1882 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1883 * to enter custom values. Historically, comboboxes used to be a dedicated
1884 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1885 * with a set of enforced default properties for easier instantiation.
1887 * UI widget instances are usually not supposed to be created by view code
1888 * directly, instead they're implicitely created by `LuCI.form` when
1889 * instantiating CBI forms.
1891 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1892 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1893 * external JavaScript, use `L.require("ui").then(...)` and access the
1894 * `Combobox` property of the class instance value.
1896 * @param {string|string[]} [value=null]
1897 * The initial input value(s).
1899 * @param {Object<string, *>} choices
1900 * Object containing the selectable choices of the widget. The object keys
1901 * serve as values for the different choices while the values are used as
1904 * @param {LuCI.ui.Combobox.InitOptions} [options]
1905 * Object describing the widget specific options to initialize the dropdown.
1907 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1909 * Comboboxes support the same properties as
1910 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1911 * specific values for the following properties:
1913 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1914 * @memberof LuCI.ui.Combobox
1916 * @property {boolean} multiple=false
1917 * Since Comboboxes never allow selecting multiple values, this property
1918 * is forcibly set to `false`.
1920 * @property {boolean} create=true
1921 * Since Comboboxes always allow custom choice values, this property is
1922 * forcibly set to `true`.
1924 * @property {boolean} optional=true
1925 * Since Comboboxes are always optional, this property is forcibly set to
1928 __init__: function(value, choices, options) {
1929 this.super('__init__', [ value, choices, Object.assign({
1930 select_placeholder: _('-- Please choose --'),
1931 custom_placeholder: _('-- custom --'),
1943 * Instantiate a combo button widget offering multiple action choices.
1945 * @constructor ComboButton
1947 * @augments LuCI.ui.Dropdown
1951 * The `ComboButton` class implements a button element which can be expanded
1952 * into a dropdown to chose from a set of different action choices.
1954 * UI widget instances are usually not supposed to be created by view code
1955 * directly, instead they're implicitely created by `LuCI.form` when
1956 * instantiating CBI forms.
1958 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1959 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1960 * external JavaScript, use `L.require("ui").then(...)` and access the
1961 * `ComboButton` property of the class instance value.
1963 * @param {string|string[]} [value=null]
1964 * The initial input value(s).
1966 * @param {Object<string, *>} choices
1967 * Object containing the selectable choices of the widget. The object keys
1968 * serve as values for the different choices while the values are used as
1971 * @param {LuCI.ui.ComboButton.InitOptions} [options]
1972 * Object describing the widget specific options to initialize the button.
1974 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1976 * ComboButtons support the same properties as
1977 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1978 * specific values for some properties and add aditional button specific
1981 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1982 * @memberof LuCI.ui.ComboButton
1984 * @property {boolean} multiple=false
1985 * Since ComboButtons never allow selecting multiple actions, this property
1986 * is forcibly set to `false`.
1988 * @property {boolean} create=false
1989 * Since ComboButtons never allow creating custom choices, this property
1990 * is forcibly set to `false`.
1992 * @property {boolean} optional=false
1993 * Since ComboButtons must always select one action, this property is
1994 * forcibly set to `false`.
1996 * @property {Object<string, string>} [classes]
1997 * Specifies a mapping of choice values to CSS class names. If an action
1998 * choice is selected by the user and if a corresponding entry exists in
1999 * the `classes` object, the class names corresponding to the selected
2000 * value are set on the button element.
2002 * This is useful to apply different button styles, such as colors, to the
2003 * combined button depending on the selected action.
2005 * @property {function} [click]
2006 * Specifies a handler function to invoke when the user clicks the button.
2007 * This function will be called with the button DOM node as `this` context
2008 * and receive the DOM click event as first as well as the selected action
2009 * choice value as second argument.
2011 __init__: function(value, choices, options) {
2012 this.super('__init__', [ value, choices, Object.assign({
2022 render: function(/* ... */) {
2023 var node = UIDropdown.prototype.render.apply(this, arguments),
2024 val = this.getValue();
2026 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2027 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2033 handleClick: function(ev) {
2034 var sb = ev.currentTarget,
2037 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2038 return UIDropdown.prototype.handleClick.apply(this, arguments);
2040 if (this.options.click)
2041 return this.options.click.call(sb, ev, this.getValue());
2045 toggleItem: function(sb /*, ... */) {
2046 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2047 val = this.getValue();
2049 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2050 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2052 sb.setAttribute('class', 'cbi-dropdown');
2059 * Instantiate a dynamic list widget.
2061 * @constructor DynamicList
2063 * @augments LuCI.ui.AbstractElement
2067 * The `DynamicList` class implements a widget which allows the user to specify
2068 * an arbitrary amount of input values, either from free formed text input or
2069 * from a set of predefined choices.
2071 * UI widget instances are usually not supposed to be created by view code
2072 * directly, instead they're implicitely created by `LuCI.form` when
2073 * instantiating CBI forms.
2075 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2076 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2077 * external JavaScript, use `L.require("ui").then(...)` and access the
2078 * `DynamicList` property of the class instance value.
2080 * @param {string|string[]} [value=null]
2081 * The initial input value(s).
2083 * @param {Object<string, *>} [choices]
2084 * Object containing the selectable choices of the widget. The object keys
2085 * serve as values for the different choices while the values are used as
2086 * choice labels. If omitted, no default choices are presented to the user,
2087 * instead a plain text input field is rendered allowing the user to add
2088 * arbitrary values to the dynamic list.
2090 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2091 * Object describing the widget specific options to initialize the dynamic list.
2093 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2095 * In case choices are passed to the dynamic list contructor, the widget
2096 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2097 * but enforces specific values for some dropdown properties.
2099 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2100 * @memberof LuCI.ui.DynamicList
2102 * @property {boolean} multiple=false
2103 * Since dynamic lists never allow selecting multiple choices when adding
2104 * another list item, this property is forcibly set to `false`.
2106 * @property {boolean} optional=true
2107 * Since dynamic lists use an embedded dropdown to present a list of
2108 * predefined choice values, the dropdown must be made optional to allow
2109 * it to remain unselected.
2111 __init__: function(values, choices, options) {
2112 if (!Array.isArray(values))
2113 values = (values != null && values != '') ? [ values ] : [];
2115 if (typeof(choices) != 'object')
2118 this.values = values;
2119 this.choices = choices;
2120 this.options = Object.assign({}, options, {
2127 render: function() {
2129 'id': this.options.id,
2130 'class': 'cbi-dynlist',
2131 'disabled': this.options.disabled ? '' : null
2132 }, E('div', { 'class': 'add-item' }));
2135 if (this.options.placeholder != null)
2136 this.options.select_placeholder = this.options.placeholder;
2138 var cbox = new UICombobox(null, this.choices, this.options);
2140 dl.lastElementChild.appendChild(cbox.render());
2143 var inputEl = E('input', {
2144 'id': this.options.id ? 'widget.' + this.options.id : null,
2146 'class': 'cbi-input-text',
2147 'placeholder': this.options.placeholder,
2148 'disabled': this.options.disabled ? '' : null
2151 dl.lastElementChild.appendChild(inputEl);
2152 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2154 if (this.options.datatype || this.options.validate)
2155 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2156 true, this.options.validate, 'blur', 'keyup');
2159 for (var i = 0; i < this.values.length; i++) {
2160 var label = this.choices ? this.choices[this.values[i]] : null;
2162 if (dom.elem(label))
2163 label = label.cloneNode(true);
2165 this.addItem(dl, this.values[i], label);
2168 return this.bind(dl);
2172 bind: function(dl) {
2173 dl.addEventListener('click', L.bind(this.handleClick, this));
2174 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2175 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2179 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2180 this.setChangeEvents(dl, 'cbi-dynlist-change');
2182 dom.bindClassInstance(dl, this);
2188 addItem: function(dl, value, text, flash) {
2190 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2191 E('span', {}, [ text || value ]),
2194 'name': this.options.name,
2195 'value': value })]);
2197 dl.querySelectorAll('.item').forEach(function(item) {
2201 var hidden = item.querySelector('input[type="hidden"]');
2203 if (hidden && hidden.parentNode !== item)
2206 if (hidden && hidden.value === value)
2211 var ai = dl.querySelector('.add-item');
2212 ai.parentNode.insertBefore(new_item, ai);
2215 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2227 removeItem: function(dl, item) {
2228 var value = item.querySelector('input[type="hidden"]').value;
2229 var sb = dl.querySelector('.cbi-dropdown');
2231 sb.querySelectorAll('ul > li').forEach(function(li) {
2232 if (li.getAttribute('data-value') === value) {
2233 if (li.hasAttribute('dynlistcustom'))
2234 li.parentNode.removeChild(li);
2236 li.removeAttribute('unselectable');
2240 item.parentNode.removeChild(item);
2242 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2254 handleClick: function(ev) {
2255 var dl = ev.currentTarget,
2256 item = findParent(ev.target, '.item');
2258 if (this.options.disabled)
2262 this.removeItem(dl, item);
2264 else if (matchesElem(ev.target, '.cbi-button-add')) {
2265 var input = ev.target.previousElementSibling;
2266 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2267 this.addItem(dl, input.value, null, true);
2274 handleDropdownChange: function(ev) {
2275 var dl = ev.currentTarget,
2276 sbIn = ev.detail.instance,
2277 sbEl = ev.detail.element,
2278 sbVal = ev.detail.value;
2283 sbIn.setValues(sbEl, null);
2284 sbVal.element.setAttribute('unselectable', '');
2286 if (sbVal.element.hasAttribute('created')) {
2287 sbVal.element.removeAttribute('created');
2288 sbVal.element.setAttribute('dynlistcustom', '');
2291 var label = sbVal.text;
2293 if (sbVal.element) {
2296 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2297 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2300 this.addItem(dl, sbVal.value, label, true);
2304 handleKeydown: function(ev) {
2305 var dl = ev.currentTarget,
2306 item = findParent(ev.target, '.item');
2309 switch (ev.keyCode) {
2310 case 8: /* backspace */
2311 if (item.previousElementSibling)
2312 item.previousElementSibling.focus();
2314 this.removeItem(dl, item);
2317 case 46: /* delete */
2318 if (item.nextElementSibling) {
2319 if (item.nextElementSibling.classList.contains('item'))
2320 item.nextElementSibling.focus();
2322 item.nextElementSibling.firstElementChild.focus();
2325 this.removeItem(dl, item);
2329 else if (matchesElem(ev.target, '.cbi-input-text')) {
2330 switch (ev.keyCode) {
2331 case 13: /* enter */
2332 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2333 this.addItem(dl, ev.target.value, null, true);
2334 ev.target.value = '';
2339 ev.preventDefault();
2346 getValue: function() {
2347 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2348 input = this.node.querySelector('.add-item > input[type="text"]'),
2351 for (var i = 0; i < items.length; i++)
2352 v.push(items[i].value);
2354 if (input && input.value != null && input.value.match(/\S/) &&
2355 input.classList.contains('cbi-input-invalid') == false &&
2356 v.filter(function(s) { return s == input.value }).length == 0)
2357 v.push(input.value);
2363 setValue: function(values) {
2364 if (!Array.isArray(values))
2365 values = (values != null && values != '') ? [ values ] : [];
2367 var items = this.node.querySelectorAll('.item');
2369 for (var i = 0; i < items.length; i++)
2370 if (items[i].parentNode === this.node)
2371 this.removeItem(this.node, items[i]);
2373 for (var i = 0; i < values.length; i++)
2374 this.addItem(this.node, values[i],
2375 this.choices ? this.choices[values[i]] : null);
2379 * Add new suggested choices to the dynamic list.
2381 * This function adds further choices to an existing dynamic list,
2382 * ignoring choice values which are already present.
2385 * @memberof LuCI.ui.DynamicList
2386 * @param {string[]} values
2387 * The choice values to add to the dynamic lists suggestion dropdown.
2389 * @param {Object<string, *>} labels
2390 * The choice label values to use when adding suggested choices. If no
2391 * label is found for a particular choice value, the value itself is used
2392 * as label text. Choice labels may be any valid value accepted by
2393 * {@link LuCI.dom#content}.
2395 addChoices: function(values, labels) {
2396 var dl = this.node.lastElementChild.firstElementChild;
2397 dom.callClassMethod(dl, 'addChoices', values, labels);
2401 * Remove all existing choices from the dynamic list.
2403 * This function removes all preexisting suggested choices from the widget.
2406 * @memberof LuCI.ui.DynamicList
2408 clearChoices: function() {
2409 var dl = this.node.lastElementChild.firstElementChild;
2410 dom.callClassMethod(dl, 'clearChoices');
2415 * Instantiate a hidden input field widget.
2417 * @constructor Hiddenfield
2419 * @augments LuCI.ui.AbstractElement
2423 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2424 * which allows to store form data without exposing it to the user.
2426 * UI widget instances are usually not supposed to be created by view code
2427 * directly, instead they're implicitely created by `LuCI.form` when
2428 * instantiating CBI forms.
2430 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2431 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2432 * external JavaScript, use `L.require("ui").then(...)` and access the
2433 * `Hiddenfield` property of the class instance value.
2435 * @param {string|string[]} [value=null]
2436 * The initial input value.
2438 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2439 * Object describing the widget specific options to initialize the hidden input.
2441 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2442 __init__: function(value, options) {
2444 this.options = Object.assign({
2450 render: function() {
2451 var hiddenEl = E('input', {
2452 'id': this.options.id,
2457 return this.bind(hiddenEl);
2461 bind: function(hiddenEl) {
2462 this.node = hiddenEl;
2464 dom.bindClassInstance(hiddenEl, this);
2470 getValue: function() {
2471 return this.node.value;
2475 setValue: function(value) {
2476 this.node.value = value;
2481 * Instantiate a file upload widget.
2483 * @constructor FileUpload
2485 * @augments LuCI.ui.AbstractElement
2489 * The `FileUpload` class implements a widget which allows the user to upload,
2490 * browse, select and delete files beneath a predefined remote directory.
2492 * UI widget instances are usually not supposed to be created by view code
2493 * directly, instead they're implicitely created by `LuCI.form` when
2494 * instantiating CBI forms.
2496 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2497 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2498 * external JavaScript, use `L.require("ui").then(...)` and access the
2499 * `FileUpload` property of the class instance value.
2501 * @param {string|string[]} [value=null]
2502 * The initial input value.
2504 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2505 * Object describing the widget specific options to initialize the file
2508 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2510 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2511 * the following properties are recognized:
2513 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2514 * @memberof LuCI.ui.FileUpload
2516 * @property {boolean} [show_hidden=false]
2517 * Specifies whether hidden files should be displayed when browsing remote
2518 * files. Note that this is not a security feature, hidden files are always
2519 * present in the remote file listings received, this option merely controls
2520 * whether they're displayed or not.
2522 * @property {boolean} [enable_upload=true]
2523 * Specifies whether the widget allows the user to upload files. If set to
2524 * `false`, only existing files may be selected. Note that this is not a
2525 * security feature. Whether file upload requests are accepted remotely
2526 * depends on the ACL setup for the current session. This option merely
2527 * controls whether the upload controls are rendered or not.
2529 * @property {boolean} [enable_remove=true]
2530 * Specifies whether the widget allows the user to delete remove files.
2531 * If set to `false`, existing files may not be removed. Note that this is
2532 * not a security feature. Whether file delete requests are accepted
2533 * remotely depends on the ACL setup for the current session. This option
2534 * merely controls whether the file remove controls are rendered or not.
2536 * @property {string} [root_directory=/etc/luci-uploads]
2537 * Specifies the remote directory the upload and file browsing actions take
2538 * place in. Browsing to directories outside of the root directory is
2539 * prevented by the widget. Note that this is not a security feature.
2540 * Whether remote directories are browseable or not solely depends on the
2541 * ACL setup for the current session.
2543 __init__: function(value, options) {
2545 this.options = Object.assign({
2547 enable_upload: true,
2548 enable_remove: true,
2549 root_directory: '/etc/luci-uploads'
2554 bind: function(browserEl) {
2555 this.node = browserEl;
2557 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2558 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2560 dom.bindClassInstance(browserEl, this);
2566 render: function() {
2567 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2570 if (L.isObject(stat) && stat.type != 'directory')
2573 if (this.stat != null)
2574 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2575 else if (this.value != null)
2576 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2578 label = [ _('Select file…') ];
2580 return this.bind(E('div', { 'id': this.options.id }, [
2583 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2584 'disabled': this.options.disabled ? '' : null
2587 'class': 'cbi-filebrowser'
2591 'name': this.options.name,
2599 truncatePath: function(path) {
2600 if (path.length > 50)
2601 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2607 iconForType: function(type) {
2611 'src': L.resource('cbi/link.gif'),
2612 'title': _('Symbolic link'),
2618 'src': L.resource('cbi/folder.gif'),
2619 'title': _('Directory'),
2625 'src': L.resource('cbi/file.gif'),
2633 canonicalizePath: function(path) {
2634 return path.replace(/\/{2,}/, '/')
2635 .replace(/\/\.(\/|$)/g, '/')
2636 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2637 .replace(/\/$/, '');
2641 splitPath: function(path) {
2642 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2643 cpath = this.canonicalizePath(path || '/');
2645 if (cpath.length <= croot.length)
2648 if (cpath.charAt(croot.length) != '/')
2651 var parts = cpath.substring(croot.length + 1).split(/\//);
2653 parts.unshift(croot);
2659 handleUpload: function(path, list, ev) {
2660 var form = ev.target.parentNode,
2661 fileinput = form.querySelector('input[type="file"]'),
2662 nameinput = form.querySelector('input[type="text"]'),
2663 filename = (nameinput.value != null ? nameinput.value : '').trim();
2665 ev.preventDefault();
2667 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2670 var existing = list.filter(function(e) { return e.name == filename })[0];
2672 if (existing != null && existing.type == 'directory')
2673 return alert(_('A directory with the same name already exists.'));
2674 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2677 var data = new FormData();
2679 data.append('sessionid', L.env.sessionid);
2680 data.append('filename', path + '/' + filename);
2681 data.append('filedata', fileinput.files[0]);
2683 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2684 progress: L.bind(function(btn, ev) {
2685 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2687 }).then(L.bind(function(path, ev, res) {
2688 var reply = res.json();
2690 if (L.isObject(reply) && reply.failure)
2691 alert(_('Upload request failed: %s').format(reply.message));
2693 return this.handleSelect(path, null, ev);
2694 }, this, path, ev));
2698 handleDelete: function(path, fileStat, ev) {
2699 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2700 name = path.replace(/^.+\//, ''),
2703 ev.preventDefault();
2705 if (fileStat.type == 'directory')
2706 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2708 msg = _('Do you really want to delete "%s" ?').format(name);
2711 var button = this.node.firstElementChild,
2712 hidden = this.node.lastElementChild;
2714 if (path == hidden.value) {
2715 dom.content(button, _('Select file…'));
2719 return fs.remove(path).then(L.bind(function(parent, ev) {
2720 return this.handleSelect(parent, null, ev);
2721 }, this, parent, ev)).catch(function(err) {
2722 alert(_('Delete request failed: %s').format(err.message));
2728 renderUpload: function(path, list) {
2729 if (!this.options.enable_upload)
2735 'class': 'btn cbi-button-positive',
2736 'click': function(ev) {
2737 var uploadForm = ev.target.nextElementSibling,
2738 fileInput = uploadForm.querySelector('input[type="file"]');
2740 ev.target.style.display = 'none';
2741 uploadForm.style.display = '';
2744 }, _('Upload file…')),
2745 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2748 'style': 'display:none',
2749 'change': function(ev) {
2750 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2751 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2753 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2754 uploadbtn.disabled = false;
2759 'click': function(ev) {
2760 ev.preventDefault();
2761 ev.target.previousElementSibling.click();
2763 }, [ _('Browse…') ]),
2764 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2766 'class': 'btn cbi-button-save',
2767 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2769 }, [ _('Upload file') ])
2775 renderListing: function(container, path, list) {
2776 var breadcrumb = E('p'),
2779 list.sort(function(a, b) {
2780 var isDirA = (a.type == 'directory'),
2781 isDirB = (b.type == 'directory');
2783 if (isDirA != isDirB)
2784 return isDirA < isDirB;
2786 return a.name > b.name;
2789 for (var i = 0; i < list.length; i++) {
2790 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2793 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2794 selected = (entrypath == this.node.lastElementChild.value),
2795 mtime = new Date(list[i].mtime * 1000);
2797 rows.appendChild(E('li', [
2798 E('div', { 'class': 'name' }, [
2799 this.iconForType(list[i].type),
2803 'style': selected ? 'font-weight:bold' : null,
2804 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2805 entrypath, list[i].type != 'directory' ? list[i] : null)
2806 }, '%h'.format(list[i].name))
2808 E('div', { 'class': 'mtime hide-xs' }, [
2809 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2810 mtime.getFullYear(),
2811 mtime.getMonth() + 1,
2818 selected ? E('button', {
2820 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2821 }, [ _('Deselect') ]) : '',
2822 this.options.enable_remove ? E('button', {
2823 'class': 'btn cbi-button-negative',
2824 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2825 }, [ _('Delete') ]) : ''
2830 if (!rows.firstElementChild)
2831 rows.appendChild(E('em', _('No entries in this directory')));
2833 var dirs = this.splitPath(path),
2836 for (var i = 0; i < dirs.length; i++) {
2837 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2838 dom.append(breadcrumb, [
2842 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2843 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2847 dom.content(container, [
2850 E('div', { 'class': 'right' }, [
2851 this.renderUpload(path, list),
2855 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2862 handleCancel: function(ev) {
2863 var button = this.node.firstElementChild,
2864 browser = button.nextElementSibling;
2866 browser.classList.remove('open');
2867 button.style.display = '';
2869 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2871 ev.preventDefault();
2875 handleReset: function(ev) {
2876 var button = this.node.firstElementChild,
2877 hidden = this.node.lastElementChild;
2880 dom.content(button, _('Select file…'));
2882 this.handleCancel(ev);
2886 handleSelect: function(path, fileStat, ev) {
2887 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2888 ul = browser.querySelector('ul');
2890 if (fileStat == null) {
2891 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2892 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2895 var button = this.node.firstElementChild,
2896 hidden = this.node.lastElementChild;
2898 path = this.canonicalizePath(path);
2900 dom.content(button, [
2901 this.iconForType(fileStat.type),
2902 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2905 browser.classList.remove('open');
2906 button.style.display = '';
2907 hidden.value = path;
2909 this.stat = Object.assign({ path: path }, fileStat);
2910 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2915 handleFileBrowser: function(ev) {
2916 var button = ev.target,
2917 browser = button.nextElementSibling,
2918 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2920 if (path.indexOf(this.options.root_directory) != 0)
2921 path = this.options.root_directory;
2923 ev.preventDefault();
2925 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2926 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2927 dom.findClassInstance(browserEl).handleCancel(ev);
2930 button.style.display = 'none';
2931 browser.classList.add('open');
2933 return this.renderListing(browser, path, list);
2934 }, this, button, browser, path));
2938 getValue: function() {
2939 return this.node.lastElementChild.value;
2943 setValue: function(value) {
2944 this.node.lastElementChild.value = value;
2949 function scrubMenu(node) {
2950 var hasSatisfiedChild = false;
2952 if (L.isObject(node.children)) {
2953 for (var k in node.children) {
2954 var child = scrubMenu(node.children[k]);
2957 hasSatisfiedChild = hasSatisfiedChild || child.satisfied;
2961 if (L.isObject(node.action) &&
2962 node.action.type == 'firstchild' &&
2963 hasSatisfiedChild == false)
2964 node.satisfied = false;
2979 var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
2981 * @typedef {Object} MenuNode
2982 * @memberof LuCI.ui.menu
2984 * @property {string} name - The internal name of the node, as used in the URL
2985 * @property {number} order - The sort index of the menu node
2986 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
2987 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
2988 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
2989 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
2993 * Load and cache current menu tree.
2995 * @returns {Promise<LuCI.ui.menu.MenuNode>}
2996 * Returns a promise resolving to the root element of the menu tree.
2999 if (this.menu == null)
3000 this.menu = session.getLocalData('menu');
3002 if (!L.isObject(this.menu)) {
3003 this.menu = request.get(L.url('admin/menu')).then(L.bind(function(menu) {
3004 this.menu = scrubMenu(menu.json());
3005 session.setLocalData('menu', this.menu);
3011 return Promise.resolve(this.menu);
3015 * Flush the internal menu cache to force loading a new structure on the
3018 flushCache: function() {
3019 session.setLocalData('menu', null);
3023 * @param {LuCI.ui.menu.MenuNode} [node]
3024 * The menu node to retrieve the children for. Defaults to the menu's
3025 * internal root node if omitted.
3027 * @returns {LuCI.ui.menu.MenuNode[]}
3028 * Returns an array of child menu nodes.
3030 getChildren: function(node) {
3036 for (var k in node.children) {
3037 if (!node.children.hasOwnProperty(k))
3040 if (!node.children[k].satisfied)
3043 if (!node.children[k].hasOwnProperty('title'))
3046 children.push(Object.assign(node.children[k], { name: k }));
3049 return children.sort(function(a, b) {
3050 return ((a.order || 1000) - (b.order || 1000));
3061 * Provides high level UI helper functionality.
3062 * To import the class in views, use `'require ui'`, to import it in
3063 * external JavaScript, use `L.require("ui").then(...)`.
3065 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
3066 __init__: function() {
3067 modalDiv = document.body.appendChild(
3068 dom.create('div', { id: 'modal_overlay' },
3069 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
3071 tooltipDiv = document.body.appendChild(
3072 dom.create('div', { class: 'cbi-tooltip' }));
3074 /* setup old aliases */
3075 L.showModal = this.showModal;
3076 L.hideModal = this.hideModal;
3077 L.showTooltip = this.showTooltip;
3078 L.hideTooltip = this.hideTooltip;
3079 L.itemlist = this.itemlist;
3081 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
3082 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
3083 document.addEventListener('focus', this.showTooltip.bind(this), true);
3084 document.addEventListener('blur', this.hideTooltip.bind(this), true);
3086 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
3087 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
3088 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
3092 * Display a modal overlay dialog with the specified contents.
3094 * The modal overlay dialog covers the current view preventing interaction
3095 * with the underlying view contents. Only one modal dialog instance can
3096 * be opened. Invoking showModal() while a modal dialog is already open will
3097 * replace the open dialog with a new one having the specified contents.
3099 * Additional CSS class names may be passed to influence the appearence of
3100 * the dialog. Valid values for the classes depend on the underlying theme.
3102 * @see LuCI.dom.content
3104 * @param {string} [title]
3105 * The title of the dialog. If `null`, no title element will be rendered.
3107 * @param {*} contents
3108 * The contents to add to the modal dialog. This should be a DOM node or
3109 * a document fragment in most cases. The value is passed as-is to the
3110 * `dom.content()` function - refer to its documentation for applicable
3113 * @param {...string} [classes]
3114 * A number of extra CSS class names which are set on the modal dialog
3118 * Returns a DOM Node representing the modal dialog element.
3120 showModal: function(title, children /* , ... */) {
3121 var dlg = modalDiv.firstElementChild;
3123 dlg.setAttribute('class', 'modal');
3125 for (var i = 2; i < arguments.length; i++)
3126 dlg.classList.add(arguments[i]);
3128 dom.content(dlg, dom.create('h4', {}, title));
3129 dom.append(dlg, children);
3131 document.body.classList.add('modal-overlay-active');
3137 * Close the open modal overlay dialog.
3139 * This function will close an open modal dialog and restore the normal view
3140 * behaviour. It has no effect if no modal dialog is currently open.
3142 * Note that this function is stand-alone, it does not rely on `this` and
3143 * will not invoke other class functions so it suitable to be used as event
3144 * handler as-is without the need to bind it first.
3146 hideModal: function() {
3147 document.body.classList.remove('modal-overlay-active');
3151 showTooltip: function(ev) {
3152 var target = findParent(ev.target, '[data-tooltip]');
3157 if (tooltipTimeout !== null) {
3158 window.clearTimeout(tooltipTimeout);
3159 tooltipTimeout = null;
3162 var rect = target.getBoundingClientRect(),
3163 x = rect.left + window.pageXOffset,
3164 y = rect.top + rect.height + window.pageYOffset;
3166 tooltipDiv.className = 'cbi-tooltip';
3167 tooltipDiv.innerHTML = 'â–² ';
3168 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3170 if (target.hasAttribute('data-tooltip-style'))
3171 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3173 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3174 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3175 tooltipDiv.firstChild.data = 'â–¼ ' + tooltipDiv.firstChild.data.substr(2);
3178 tooltipDiv.style.top = y + 'px';
3179 tooltipDiv.style.left = x + 'px';
3180 tooltipDiv.style.opacity = 1;
3182 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3184 detail: { target: target }
3189 hideTooltip: function(ev) {
3190 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3191 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3194 if (tooltipTimeout !== null) {
3195 window.clearTimeout(tooltipTimeout);
3196 tooltipTimeout = null;
3199 tooltipDiv.style.opacity = 0;
3200 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3202 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3206 * Add a notification banner at the top of the current view.
3208 * A notification banner is an alert message usually displayed at the
3209 * top of the current view, spanning the entire availibe width.
3210 * Notification banners will stay in place until dismissed by the user.
3211 * Multiple banners may be shown at the same time.
3213 * Additional CSS class names may be passed to influence the appearence of
3214 * the banner. Valid values for the classes depend on the underlying theme.
3216 * @see LuCI.dom.content
3218 * @param {string} [title]
3219 * The title of the notification banner. If `null`, no title element
3222 * @param {*} contents
3223 * The contents to add to the notification banner. This should be a DOM
3224 * node or a document fragment in most cases. The value is passed as-is
3225 * to the `dom.content()` function - refer to its documentation for
3226 * applicable values.
3228 * @param {...string} [classes]
3229 * A number of extra CSS class names which are set on the notification
3233 * Returns a DOM Node representing the notification banner element.
3235 addNotification: function(title, children /*, ... */) {
3236 var mc = document.querySelector('#maincontent') || document.body;
3237 var msg = E('div', {
3238 'class': 'alert-message fade-in',
3239 'style': 'display:flex',
3240 'transitionend': function(ev) {
3241 var node = ev.currentTarget;
3242 if (node.parentNode && node.classList.contains('fade-out'))
3243 node.parentNode.removeChild(node);
3246 E('div', { 'style': 'flex:10' }),
3247 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3250 'style': 'margin-left:auto; margin-top:auto',
3251 'click': function(ev) {
3252 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3255 }, [ _('Dismiss') ])
3260 dom.append(msg.firstElementChild, E('h4', {}, title));
3262 dom.append(msg.firstElementChild, children);
3264 for (var i = 2; i < arguments.length; i++)
3265 msg.classList.add(arguments[i]);
3267 mc.insertBefore(msg, mc.firstElementChild);
3273 * Display or update an header area indicator.
3275 * An indicator is a small label displayed in the header area of the screen
3276 * providing few amounts of status information such as item counts or state
3277 * toggle indicators.
3279 * Multiple indicators may be shown at the same time and indicator labels
3280 * may be made clickable to display extended information or to initiate
3283 * Indicators can either use a default `active` or a less accented `inactive`
3284 * style which is useful for indicators representing state toggles.
3286 * @param {string} id
3287 * The ID of the indicator. If an indicator with the given ID already exists,
3288 * it is updated with the given label and style.
3290 * @param {string} label
3291 * The text to display in the indicator label.
3293 * @param {function} [handler]
3294 * A handler function to invoke when the indicator label is clicked/touched
3295 * by the user. If omitted, the indicator is not clickable/touchable.
3297 * Note that this parameter only applies to new indicators, when updating
3298 * existing labels it is ignored.
3300 * @param {string} [style=active]
3301 * The indicator style to use. May be either `active` or `inactive`.
3303 * @returns {boolean}
3304 * Returns `true` when the indicator has been updated or `false` when no
3305 * changes were made.
3307 showIndicator: function(id, label, handler, style) {
3308 if (indicatorDiv == null) {
3309 indicatorDiv = document.body.querySelector('#indicators');
3311 if (indicatorDiv == null)
3315 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3316 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id));
3318 if (indicatorElem == null) {
3319 var beforeElem = null;
3321 for (beforeElem = indicatorDiv.firstElementChild;
3323 beforeElem = beforeElem.nextElementSibling)
3324 if (beforeElem.getAttribute('data-indicator') > id)
3327 indicatorElem = indicatorDiv.insertBefore(E('span', {
3328 'data-indicator': id,
3329 'data-clickable': handlerFn ? true : null,
3331 }, ['']), beforeElem);
3334 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3337 indicatorElem.firstChild.data = label;
3338 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3343 * Remove an header area indicator.
3345 * This function removes the given indicator label from the header indicator
3346 * area. When the given indicator is not found, this function does nothing.
3348 * @param {string} id
3349 * The ID of the indicator to remove.
3351 * @returns {boolean}
3352 * Returns `true` when the indicator has been removed or `false` when the
3353 * requested indicator was not found.
3355 hideIndicator: function(id) {
3356 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3358 if (indicatorElem == null)
3361 indicatorDiv.removeChild(indicatorElem);
3366 * Formats a series of label/value pairs into list-like markup.
3368 * This function transforms a flat array of alternating label and value
3369 * elements into a list-like markup, using the values in `separators` as
3370 * separators and appends the resulting nodes to the given parent DOM node.
3372 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3373 * `<strong>` element and the value corresponding to the label are
3374 * subsequently wrapped into a `<span class="nowrap">` element.
3376 * The resulting `<span>` element tuples are joined by the given separators
3377 * to form the final markup which is appened to the given parent DOM node.
3379 * @param {Node} node
3380 * The parent DOM node to append the markup to. Any previous child elements
3383 * @param {Array<*>} items
3384 * An alternating array of labels and values. The label values will be
3385 * converted to plain strings, the values are used as-is and may be of
3386 * any type accepted by `LuCI.dom.content()`.
3388 * @param {*|Array<*>} [separators=[E('br')]]
3389 * A single value or an array of separator values to separate each
3390 * label/value pair with. The function will cycle through the separators
3391 * when joining the pairs. If omitted, the default separator is a sole HTML
3392 * `<br>` element. Separator values are used as-is and may be of any type
3393 * accepted by `LuCI.dom.content()`.
3396 * Returns the parent DOM node the formatted markup has been added to.
3398 itemlist: function(node, items, separators) {
3401 if (!Array.isArray(separators))
3402 separators = [ separators || E('br') ];
3404 for (var i = 0; i < items.length; i += 2) {
3405 if (items[i+1] !== null && items[i+1] !== undefined) {
3406 var sep = separators[(i/2) % separators.length],
3409 children.push(E('span', { class: 'nowrap' }, [
3410 items[i] ? E('strong', items[i] + ': ') : '',
3414 if ((i+2) < items.length)
3415 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3419 dom.content(node, children);
3430 * The `tabs` class handles tab menu groups used throughout the view area.
3431 * It takes care of setting up tab groups, tracking their state and handling
3434 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3435 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3436 * external JavaScript, use `L.require("ui").then(...)` and access the
3437 * `tabs` property of the class instance value.
3439 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3442 var groups = [], prevGroup = null, currGroup = null;
3444 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3445 var parent = tab.parentNode;
3447 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3450 if (!parent.hasAttribute('data-tab-group'))
3451 parent.setAttribute('data-tab-group', groups.length);
3453 currGroup = +parent.getAttribute('data-tab-group');
3455 if (currGroup !== prevGroup) {
3456 prevGroup = currGroup;
3458 if (!groups[currGroup])
3459 groups[currGroup] = [];
3462 groups[currGroup].push(tab);
3465 for (var i = 0; i < groups.length; i++)
3466 this.initTabGroup(groups[i]);
3468 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3474 * Initializes a new tab group from the given tab pane collection.
3476 * This function cycles through the given tab pane DOM nodes, extracts
3477 * their tab IDs, titles and active states, renders a corresponding
3478 * tab menu and prepends it to the tab panes common parent DOM node.
3480 * The tab menu labels will be set to the value of the `data-tab-title`
3481 * attribute of each corresponding pane. The last pane with the
3482 * `data-tab-active` attribute set to `true` will be selected by default.
3484 * If no pane is marked as active, the first one will be preselected.
3487 * @memberof LuCI.ui.tabs
3488 * @param {Array<Node>|NodeList} panes
3489 * A collection of tab panes to build a tab group menu for. May be a
3490 * plain array of DOM nodes or a NodeList collection, such as the result
3491 * of a `querySelectorAll()` call or the `.childNodes` property of a
3494 initTabGroup: function(panes) {
3495 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3498 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3499 group = panes[0].parentNode,
3500 groupId = +group.getAttribute('data-tab-group'),
3503 if (group.getAttribute('data-initialized') === 'true')
3506 for (var i = 0, pane; pane = panes[i]; i++) {
3507 var name = pane.getAttribute('data-tab'),
3508 title = pane.getAttribute('data-tab-title'),
3509 active = pane.getAttribute('data-tab-active') === 'true';
3511 menu.appendChild(E('li', {
3512 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3513 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3517 'click': this.switchTab.bind(this)
3524 group.parentNode.insertBefore(menu, group);
3525 group.setAttribute('data-initialized', true);
3527 if (selected === null) {
3528 selected = this.getActiveTabId(panes[0]);
3530 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3531 for (var i = 0; i < panes.length; i++) {
3532 if (!this.isEmptyPane(panes[i])) {
3539 menu.childNodes[selected].classList.add('cbi-tab');
3540 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3541 panes[selected].setAttribute('data-tab-active', 'true');
3543 this.setActiveTabId(panes[selected], selected);
3546 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3547 detail: { tab: panes[selected].getAttribute('data-tab') }
3550 this.updateTabs(group);
3554 * Checks whether the given tab pane node is empty.
3557 * @memberof LuCI.ui.tabs
3558 * @param {Node} pane
3559 * The tab pane to check.
3561 * @returns {boolean}
3562 * Returns `true` if the pane is empty, else `false`.
3564 isEmptyPane: function(pane) {
3565 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3569 getPathForPane: function(pane) {
3570 var path = [], node = null;
3572 for (node = pane ? pane.parentNode : null;
3573 node != null && node.hasAttribute != null;
3574 node = node.parentNode)
3576 if (node.hasAttribute('data-tab'))
3577 path.unshift(node.getAttribute('data-tab'));
3578 else if (node.hasAttribute('data-section-id'))
3579 path.unshift(node.getAttribute('data-section-id'));
3582 return path.join('/');
3586 getActiveTabState: function() {
3587 var page = document.body.getAttribute('data-page'),
3588 state = session.getLocalData('tab');
3590 if (L.isObject(state) && state.page === page && L.isObject(state.paths))
3593 session.setLocalData('tab', null);
3595 return { page: page, paths: {} };
3599 getActiveTabId: function(pane) {
3600 var path = this.getPathForPane(pane);
3601 return +this.getActiveTabState().paths[path] || 0;
3605 setActiveTabId: function(pane, tabIndex) {
3606 var path = this.getPathForPane(pane),
3607 state = this.getActiveTabState();
3609 state.paths[path] = tabIndex;
3611 return session.setLocalData('tab', state);
3615 updateTabs: function(ev, root) {
3616 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3617 var menu = pane.parentNode.previousElementSibling,
3618 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3619 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3624 if (this.isEmptyPane(pane)) {
3625 tab.style.display = 'none';
3626 tab.classList.remove('flash');
3628 else if (tab.style.display === 'none') {
3629 tab.style.display = '';
3630 requestAnimationFrame(function() { tab.classList.add('flash') });
3634 tab.setAttribute('data-errors', n_errors);
3635 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3636 tab.setAttribute('data-tooltip-style', 'error');
3639 tab.removeAttribute('data-errors');
3640 tab.removeAttribute('data-tooltip');
3646 switchTab: function(ev) {
3647 var tab = ev.target.parentNode,
3648 name = tab.getAttribute('data-tab'),
3649 menu = tab.parentNode,
3650 group = menu.nextElementSibling,
3651 groupId = +group.getAttribute('data-tab-group'),
3654 ev.preventDefault();
3656 if (!tab.classList.contains('cbi-tab-disabled'))
3659 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3660 tab.classList.remove('cbi-tab');
3661 tab.classList.remove('cbi-tab-disabled');
3663 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3666 group.childNodes.forEach(function(pane) {
3667 if (dom.matches(pane, '[data-tab]')) {
3668 if (pane.getAttribute('data-tab') === name) {
3669 pane.setAttribute('data-tab-active', 'true');
3670 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3671 UI.prototype.tabs.setActiveTabId(pane, index);
3674 pane.setAttribute('data-tab-active', 'false');
3684 * @typedef {Object} FileUploadReply
3687 * @property {string} name - Name of the uploaded file without directory components
3688 * @property {number} size - Size of the uploaded file in bytes
3689 * @property {string} checksum - The MD5 checksum of the received file data
3690 * @property {string} sha256sum - The SHA256 checksum of the received file data
3694 * Display a modal file upload prompt.
3696 * This function opens a modal dialog prompting the user to select and
3697 * upload a file to a predefined remote destination path.
3699 * @param {string} path
3700 * The remote file path to upload the local file to.
3702 * @param {Node} [progessStatusNode]
3703 * An optional DOM text node whose content text is set to the progress
3704 * percentage value during file upload.
3706 * @returns {Promise<LuCI.ui.FileUploadReply>}
3707 * Returns a promise resolving to a file upload status object on success
3708 * or rejecting with an error in case the upload failed or has been
3709 * cancelled by the user.
3711 uploadFile: function(path, progressStatusNode) {
3712 return new Promise(function(resolveFn, rejectFn) {
3713 UI.prototype.showModal(_('Uploading file…'), [
3714 E('p', _('Please select the file to upload.')),
3715 E('div', { 'style': 'display:flex' }, [
3716 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3719 style: 'display:none',
3720 change: function(ev) {
3721 var modal = dom.parent(ev.target, '.modal'),
3722 body = modal.querySelector('p'),
3723 upload = modal.querySelector('.cbi-button-action.important'),
3724 file = ev.currentTarget.files[0];
3731 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3732 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3736 upload.disabled = false;
3742 'click': function(ev) {
3743 ev.target.previousElementSibling.click();
3745 }, [ _('Browse…') ])
3747 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3750 'click': function() {
3751 UI.prototype.hideModal();
3752 rejectFn(new Error('Upload has been cancelled'));
3754 }, [ _('Cancel') ]),
3757 'class': 'btn cbi-button-action important',
3759 'click': function(ev) {
3760 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3762 if (!input.files[0])
3765 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3767 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3769 var data = new FormData();
3771 data.append('sessionid', rpc.getSessionID());
3772 data.append('filename', path);
3773 data.append('filedata', input.files[0]);
3775 var filename = input.files[0].name;
3777 request.post(L.env.cgi_base + '/cgi-upload', data, {
3779 progress: function(pev) {
3780 var percent = (pev.loaded / pev.total) * 100;
3782 if (progressStatusNode)
3783 progressStatusNode.data = '%.2f%%'.format(percent);
3785 progress.setAttribute('title', '%.2f%%'.format(percent));
3786 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3788 }).then(function(res) {
3789 var reply = res.json();
3791 UI.prototype.hideModal();
3793 if (L.isObject(reply) && reply.failure) {
3794 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3795 rejectFn(new Error(reply.failure));
3798 reply.name = filename;
3802 UI.prototype.hideModal();
3814 * Perform a device connectivity test.
3816 * Attempt to fetch a well known ressource from the remote device via HTTP
3817 * in order to test connectivity. This function is mainly useful to wait
3818 * for the router to come back online after a reboot or reconfiguration.
3820 * @param {string} [proto=http]
3821 * The protocol to use for fetching the resource. May be either `http`
3822 * (the default) or `https`.
3824 * @param {string} [host=window.location.host]
3825 * Override the host address to probe. By default the current host as seen
3826 * in the address bar is probed.
3828 * @returns {Promise<Event>}
3829 * Returns a promise resolving to a `load` event in case the device is
3830 * reachable or rejecting with an `error` event in case it is not reachable
3831 * or rejecting with `null` when the connectivity check timed out.
3833 pingDevice: function(proto, ipaddr) {
3834 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3836 return new Promise(function(resolveFn, rejectFn) {
3837 var img = new Image();
3839 img.onload = resolveFn;
3840 img.onerror = rejectFn;
3842 window.setTimeout(rejectFn, 1000);
3849 * Wait for device to come back online and reconnect to it.
3851 * Poll each given hostname or IP address and navigate to it as soon as
3852 * one of the addresses becomes reachable.
3854 * @param {...string} [hosts=[window.location.host]]
3855 * The list of IP addresses and host names to check for reachability.
3856 * If omitted, the current value of `window.location.host` is used by
3859 awaitReconnect: function(/* ... */) {
3860 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3862 window.setTimeout(L.bind(function() {
3863 poll.add(L.bind(function() {
3864 var tasks = [], reachable = false;
3866 for (var i = 0; i < 2; i++)
3867 for (var j = 0; j < ipaddrs.length; j++)
3868 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3869 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3871 return Promise.all(tasks).then(function() {
3874 window.location = reachable;
3887 * The `changes` class encapsulates logic for visualizing, applying,
3888 * confirming and reverting staged UCI changesets.
3890 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3891 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3892 * external JavaScript, use `L.require("ui").then(...)` and access the
3893 * `changes` property of the class instance value.
3895 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3897 if (!L.env.sessionid)
3900 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3904 * Set the change count indicator.
3906 * This function updates or hides the UCI change count indicator,
3907 * depending on the passed change count. When the count is greater
3908 * than 0, the change indicator is displayed or updated, otherwise it
3912 * @memberof LuCI.ui.changes
3913 * @param {number} numChanges
3914 * The number of changes to indicate.
3916 setIndicator: function(n) {
3918 UI.prototype.showIndicator('uci-changes',
3919 '%s: %d'.format(_('Unsaved Changes'), n),
3920 L.bind(this.displayChanges, this));
3923 UI.prototype.hideIndicator('uci-changes');
3928 * Update the change count indicator.
3930 * This function updates the UCI change count indicator from the given
3931 * UCI changeset structure.
3934 * @memberof LuCI.ui.changes
3935 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3936 * The UCI changeset to count.
3938 renderChangeIndicator: function(changes) {
3941 for (var config in changes)
3942 if (changes.hasOwnProperty(config))
3943 n_changes += changes[config].length;
3945 this.changes = changes;
3946 this.setIndicator(n_changes);
3951 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3952 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3953 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3954 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
3955 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3956 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3957 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3958 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3959 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
3960 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3964 * Display the current changelog.
3966 * Open a modal dialog visualizing the currently staged UCI changes
3967 * and offer options to revert or apply the shown changes.
3970 * @memberof LuCI.ui.changes
3972 displayChanges: function() {
3973 var list = E('div', { 'class': 'uci-change-list' }),
3974 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3975 E('div', { 'class': 'cbi-section' }, [
3976 E('strong', _('Legend:')),
3977 E('div', { 'class': 'uci-change-legend' }, [
3978 E('div', { 'class': 'uci-change-legend-label' }, [
3979 E('ins', ' '), ' ', _('Section added') ]),
3980 E('div', { 'class': 'uci-change-legend-label' }, [
3981 E('del', ' '), ' ', _('Section removed') ]),
3982 E('div', { 'class': 'uci-change-legend-label' }, [
3983 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
3984 E('div', { 'class': 'uci-change-legend-label' }, [
3985 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
3987 E('div', { 'class': 'right' }, [
3990 'click': UI.prototype.hideModal
3991 }, [ _('Dismiss') ]), ' ',
3993 'class': 'cbi-button cbi-button-positive important',
3994 'click': L.bind(this.apply, this, true)
3995 }, [ _('Save & Apply') ]), ' ',
3997 'class': 'cbi-button cbi-button-reset',
3998 'click': L.bind(this.revert, this)
3999 }, [ _('Revert') ])])])
4002 for (var config in this.changes) {
4003 if (!this.changes.hasOwnProperty(config))
4006 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
4008 for (var i = 0, added = null; i < this.changes[config].length; i++) {
4009 var chg = this.changes[config][i],
4010 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
4012 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
4018 if (added != null && chg[1] == added[0])
4019 return '@' + added[1] + '[-1]';
4024 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
4031 if (chg[0] == 'add')
4032 added = [ chg[1], chg[2] ];
4036 list.appendChild(E('br'));
4037 dlg.classList.add('uci-dialog');
4041 displayStatus: function(type, content) {
4043 var message = UI.prototype.showModal('', '');
4045 message.classList.add('alert-message');
4046 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4049 dom.content(message, content);
4051 if (!this.was_polling) {
4052 this.was_polling = request.poll.active();
4053 request.poll.stop();
4057 UI.prototype.hideModal();
4059 if (this.was_polling)
4060 request.poll.start();
4065 rollback: function(checked) {
4067 this.displayStatus('warning spinning',
4068 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4069 .format(L.env.apply_rollback)));
4071 var call = function(r, data, duration) {
4072 if (r.status === 204) {
4073 UI.prototype.changes.displayStatus('warning', [
4074 E('h4', _('Configuration changes have been rolled back!')),
4075 E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)),
4076 E('div', { 'class': 'right' }, [
4079 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4080 }, [ _('Dismiss') ]), ' ',
4082 'class': 'btn cbi-button-action important',
4083 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4084 }, [ _('Revert changes') ]), ' ',
4086 'class': 'btn cbi-button-negative important',
4087 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4088 }, [ _('Apply unchecked') ])
4095 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4096 window.setTimeout(function() {
4097 request.request(L.url('admin/uci/confirm'), {
4099 timeout: L.env.apply_timeout * 1000,
4100 query: { sid: L.env.sessionid, token: L.env.token }
4105 call({ status: 0 });
4108 this.displayStatus('warning', [
4109 E('h4', _('Device unreachable!')),
4110 E('p', _('Could not regain access to the device after applying the configuration changes. You might need to reconnect if you modified network related settings such as the IP address or wireless security credentials.'))
4116 confirm: function(checked, deadline, override_token) {
4118 var ts = Date.now();
4120 this.displayStatus('notice');
4123 this.confirm_auth = { token: override_token };
4125 var call = function(r, data, duration) {
4126 if (Date.now() >= deadline) {
4127 window.clearTimeout(tt);
4128 UI.prototype.changes.rollback(checked);
4131 else if (r && (r.status === 200 || r.status === 204)) {
4132 document.dispatchEvent(new CustomEvent('uci-applied'));
4134 UI.prototype.changes.setIndicator(0);
4135 UI.prototype.changes.displayStatus('notice',
4136 E('p', _('Configuration changes applied.')));
4138 window.clearTimeout(tt);
4139 window.setTimeout(function() {
4140 //UI.prototype.changes.displayStatus(false);
4141 window.location = window.location.href.split('#')[0];
4142 }, L.env.apply_display * 1000);
4147 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4148 window.setTimeout(function() {
4149 request.request(L.url('admin/uci/confirm'), {
4151 timeout: L.env.apply_timeout * 1000,
4152 query: UI.prototype.changes.confirm_auth
4153 }).then(call, call);
4157 var tick = function() {
4158 var now = Date.now();
4160 UI.prototype.changes.displayStatus('notice spinning',
4161 E('p', _('Applying configuration changes… %ds')
4162 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4164 if (now >= deadline)
4167 tt = window.setTimeout(tick, 1000 - (now - ts));
4173 /* wait a few seconds for the settings to become effective */
4174 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4178 * Apply the staged configuration changes.
4180 * Start applying staged configuration changes and open a modal dialog
4181 * with a progress indication to prevent interaction with the view
4182 * during the apply process. The modal dialog will be automatically
4183 * closed and the current view reloaded once the apply process is
4187 * @memberof LuCI.ui.changes
4188 * @param {boolean} [checked=false]
4189 * Whether to perform a checked (`true`) configuration apply or an
4190 * unchecked (`false`) one.
4192 * In case of a checked apply, the configuration changes must be
4193 * confirmed within a specific time interval, otherwise the device
4194 * will begin to roll back the changes in order to restore the previous
4197 apply: function(checked) {
4198 this.displayStatus('notice spinning',
4199 E('p', _('Starting configuration apply…')));
4201 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4203 query: { sid: L.env.sessionid, token: L.env.token }
4204 }).then(function(r) {
4205 if (r.status === (checked ? 200 : 204)) {
4206 var tok = null; try { tok = r.json(); } catch(e) {}
4207 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4208 UI.prototype.changes.confirm_auth = tok;
4210 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4212 else if (checked && r.status === 204) {
4213 UI.prototype.changes.displayStatus('notice',
4214 E('p', _('There are no changes to apply')));
4216 window.setTimeout(function() {
4217 UI.prototype.changes.displayStatus(false);
4218 }, L.env.apply_display * 1000);
4221 UI.prototype.changes.displayStatus('warning',
4222 E('p', _('Apply request failed with status <code>%h</code>')
4223 .format(r.responseText || r.statusText || r.status)));
4225 window.setTimeout(function() {
4226 UI.prototype.changes.displayStatus(false);
4227 }, L.env.apply_display * 1000);
4233 * Revert the staged configuration changes.
4235 * Start reverting staged configuration changes and open a modal dialog
4236 * with a progress indication to prevent interaction with the view
4237 * during the revert process. The modal dialog will be automatically
4238 * closed and the current view reloaded once the revert process is
4242 * @memberof LuCI.ui.changes
4244 revert: function() {
4245 this.displayStatus('notice spinning',
4246 E('p', _('Reverting configuration…')));
4248 request.request(L.url('admin/uci/revert'), {
4250 query: { sid: L.env.sessionid, token: L.env.token }
4251 }).then(function(r) {
4252 if (r.status === 200) {
4253 document.dispatchEvent(new CustomEvent('uci-reverted'));
4255 UI.prototype.changes.setIndicator(0);
4256 UI.prototype.changes.displayStatus('notice',
4257 E('p', _('Changes have been reverted.')));
4259 window.setTimeout(function() {
4260 //UI.prototype.changes.displayStatus(false);
4261 window.location = window.location.href.split('#')[0];
4262 }, L.env.apply_display * 1000);
4265 UI.prototype.changes.displayStatus('warning',
4266 E('p', _('Revert request failed with status <code>%h</code>')
4267 .format(r.statusText || r.status)));
4269 window.setTimeout(function() {
4270 UI.prototype.changes.displayStatus(false);
4271 }, L.env.apply_display * 1000);
4278 * Add validation constraints to an input element.
4280 * Compile the given type expression and optional validator function into
4281 * a validation function and bind it to the specified input element events.
4283 * @param {Node} field
4284 * The DOM input element node to bind the validation constraints to.
4286 * @param {string} type
4287 * The datatype specification to describe validation constraints.
4288 * Refer to the `LuCI.validation` class documentation for details.
4290 * @param {boolean} [optional=false]
4291 * Specifies whether empty values are allowed (`true`) or not (`false`).
4292 * If an input element is not marked optional it must not be empty,
4293 * otherwise it will be marked as invalid.
4295 * @param {function} [vfunc]
4296 * Specifies a custom validation function which is invoked after the
4297 * other validation constraints are applied. The validation must return
4298 * `true` to accept the passed value. Any other return type is converted
4299 * to a string and treated as validation error message.
4301 * @param {...string} [events=blur, keyup]
4302 * The list of events to bind. Each received event will trigger a field
4303 * validation. If omitted, the `keyup` and `blur` events are bound by
4306 * @returns {function}
4307 * Returns the compiled validator function which can be used to manually
4308 * trigger field validation or to bind it to further events.
4310 * @see LuCI.validation
4312 addValidator: function(field, type, optional, vfunc /*, ... */) {
4316 var events = this.varargs(arguments, 3);
4317 if (events.length == 0)
4318 events.push('blur', 'keyup');
4321 var cbiValidator = validation.create(field, type, optional, vfunc),
4322 validatorFn = cbiValidator.validate.bind(cbiValidator);
4324 for (var i = 0; i < events.length; i++)
4325 field.addEventListener(events[i], validatorFn);
4335 * Create a pre-bound event handler function.
4337 * Generate and bind a function suitable for use in event handlers. The
4338 * generated function automatically disables the event source element
4339 * and adds an active indication to it by adding appropriate CSS classes.
4341 * It will also await any promises returned by the wrapped function and
4342 * re-enable the source element after the promises ran to completion.
4345 * The `this` context to use for the wrapped function.
4347 * @param {function|string} fn
4348 * Specifies the function to wrap. In case of a function value, the
4349 * function is used as-is. If a string is specified instead, it is looked
4350 * up in `ctx` to obtain the function to wrap. In both cases the bound
4351 * function will be invoked with `ctx` as `this` context
4353 * @param {...*} extra_args
4354 * Any further parameter as passed as-is to the bound event handler
4355 * function in the same order as passed to `createHandlerFn()`.
4357 * @returns {function|null}
4358 * Returns the pre-bound handler function which is suitable to be passed
4359 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4360 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4361 * valid function value.
4363 createHandlerFn: function(ctx, fn /*, ... */) {
4364 if (typeof(fn) == 'string')
4367 if (typeof(fn) != 'function')
4370 var arg_offset = arguments.length - 2;
4372 return Function.prototype.bind.apply(function() {
4373 var t = arguments[arg_offset].currentTarget;
4375 t.classList.add('spinning');
4381 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4382 t.classList.remove('spinning');
4385 }, this.varargs(arguments, 2, ctx));
4389 * Load specified view class path and set it up.
4391 * Transforms the given view path into a class name, requires it
4392 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4393 * resulting class instance is a descendant of
4394 * [LuCI.view]{@link LuCI.view}.
4396 * By instantiating the view class, its corresponding contents are
4397 * rendered and included into the view area. Any runtime errors are
4398 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4400 * @param {string} path
4401 * The view path to render.
4403 * @returns {Promise<LuCI.view>}
4404 * Returns a promise resolving to the loaded view instance.
4406 instantiateView: function(path) {
4407 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4409 return L.require(className).then(function(view) {
4410 if (!(view instanceof View))
4411 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4414 }).catch(function(err) {
4415 dom.content(document.querySelector('#view'), null);
4422 AbstractElement: UIElement,
4425 Textfield: UITextfield,
4426 Textarea: UITextarea,
4427 Checkbox: UICheckbox,
4429 Dropdown: UIDropdown,
4430 DynamicList: UIDynamicList,
4431 Combobox: UICombobox,
4432 ComboButton: UIComboButton,
4433 Hiddenfield: UIHiddenfield,
4434 FileUpload: UIFileUpload