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 frameEl = E('div', { 'id': this.options.id }),
448 value = (this.value != null) ? String(this.value) : '';
450 frameEl.appendChild(E('textarea', {
451 'id': this.options.id ? 'widget.' + this.options.id : null,
452 'name': this.options.name,
453 'class': 'cbi-input-textarea',
454 'readonly': this.options.readonly ? '' : null,
455 'disabled': this.options.disabled ? '' : null,
456 'placeholder': this.options.placeholder,
457 'style': !this.options.cols ? 'width:100%' : null,
458 'cols': this.options.cols,
459 'rows': this.options.rows,
460 'wrap': this.options.wrap ? '' : null
463 if (this.options.monospace)
464 frameEl.firstElementChild.style.fontFamily = 'monospace';
466 return this.bind(frameEl);
470 bind: function(frameEl) {
471 var inputEl = frameEl.firstElementChild;
475 this.setUpdateEvents(inputEl, 'keyup', 'blur');
476 this.setChangeEvents(inputEl, 'change');
478 dom.bindClassInstance(frameEl, this);
484 getValue: function() {
485 return this.node.firstElementChild.value;
489 setValue: function(value) {
490 this.node.firstElementChild.value = value;
495 * Instantiate a checkbox widget.
497 * @constructor Checkbox
499 * @augments LuCI.ui.AbstractElement
503 * The `Checkbox` class implements a simple checkbox input field.
505 * UI widget instances are usually not supposed to be created by view code
506 * directly, instead they're implicitely created by `LuCI.form` when
507 * instantiating CBI forms.
509 * This class is automatically instantiated as part of `LuCI.ui`. To use it
510 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
511 * external JavaScript, use `L.require("ui").then(...)` and access the
512 * `Checkbox` property of the class instance value.
514 * @param {string} [value=null]
515 * The initial input value.
517 * @param {LuCI.ui.Checkbox.InitOptions} [options]
518 * Object describing the widget specific options to initialize the input.
520 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
522 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
523 * the following properties are recognized:
525 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
526 * @memberof LuCI.ui.Checkbox
528 * @property {string} [value_enabled=1]
529 * Specifies the value corresponding to a checked checkbox.
531 * @property {string} [value_disabled=0]
532 * Specifies the value corresponding to an unchecked checkbox.
534 * @property {string} [hiddenname]
535 * Specifies the HTML `name` attribute of the hidden input backing the
536 * checkbox. This is a legacy property existing for compatibility reasons,
537 * it is required for HTML based form submissions.
539 __init__: function(value, options) {
541 this.options = Object.assign({
549 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
550 var frameEl = E('div', {
551 'id': this.options.id,
552 'class': 'cbi-checkbox'
555 if (this.options.hiddenname)
556 frameEl.appendChild(E('input', {
558 'name': this.options.hiddenname,
562 frameEl.appendChild(E('input', {
564 'name': this.options.name,
566 'value': this.options.value_enabled,
567 'checked': (this.value == this.options.value_enabled) ? '' : null,
568 'disabled': this.options.disabled ? '' : null,
569 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
572 frameEl.appendChild(E('label', { 'for': id }));
574 return this.bind(frameEl);
578 bind: function(frameEl) {
581 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
582 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
584 dom.bindClassInstance(frameEl, this);
590 * Test whether the checkbox is currently checked.
593 * @memberof LuCI.ui.Checkbox
595 * Returns `true` when the checkbox is currently checked, otherwise `false`.
597 isChecked: function() {
598 return this.node.lastElementChild.previousElementSibling.checked;
602 getValue: function() {
603 return this.isChecked()
604 ? this.options.value_enabled
605 : this.options.value_disabled;
609 setValue: function(value) {
610 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
615 * Instantiate a select dropdown or checkbox/radiobutton group.
617 * @constructor Select
619 * @augments LuCI.ui.AbstractElement
623 * The `Select` class implements either a traditional HTML `<select>` element
624 * or a group of checkboxes or radio buttons, depending on whether multiple
625 * values are enabled or not.
627 * UI widget instances are usually not supposed to be created by view code
628 * directly, instead they're implicitely created by `LuCI.form` when
629 * instantiating CBI forms.
631 * This class is automatically instantiated as part of `LuCI.ui`. To use it
632 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
633 * external JavaScript, use `L.require("ui").then(...)` and access the
634 * `Select` property of the class instance value.
636 * @param {string|string[]} [value=null]
637 * The initial input value(s).
639 * @param {Object<string, string>} choices
640 * Object containing the selectable choices of the widget. The object keys
641 * serve as values for the different choices while the values are used as
644 * @param {LuCI.ui.Select.InitOptions} [options]
645 * Object describing the widget specific options to initialize the inputs.
647 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
649 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
650 * the following properties are recognized:
652 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
653 * @memberof LuCI.ui.Select
655 * @property {boolean} [multiple=false]
656 * Specifies whether multiple choice values may be selected.
658 * @property {string} [widget=select]
659 * Specifies the kind of widget to render. May be either `select` or
660 * `individual`. When set to `select` an HTML `<select>` element will be
661 * used, otherwise a group of checkbox or radio button elements is created,
662 * depending on the value of the `multiple` option.
664 * @property {string} [orientation=horizontal]
665 * Specifies whether checkbox / radio button groups should be rendered
666 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
669 * @property {boolean|string[]} [sort=false]
670 * Specifies if and how to sort choice values. If set to `true`, the choice
671 * values will be sorted alphabetically. If set to an array of strings, the
672 * choice sort order is derived from the array.
674 * @property {number} [size]
675 * Specifies the HTML `size` attribute to set on the `<select>` element.
676 * Only applicable to the `select` widget type.
678 * @property {string} [placeholder=-- Please choose --]
679 * Specifies a placeholder text which is displayed when no choice is
680 * selected yet. Only applicable to the `select` widget type.
682 __init__: function(value, choices, options) {
683 if (!L.isObject(choices))
686 if (!Array.isArray(value))
687 value = (value != null && value != '') ? [ value ] : [];
689 if (!options.multiple && value.length > 1)
693 this.choices = choices;
694 this.options = Object.assign({
697 orientation: 'horizontal'
700 if (this.choices.hasOwnProperty(''))
701 this.options.optional = true;
706 var frameEl = E('div', { 'id': this.options.id }),
707 keys = Object.keys(this.choices);
709 if (this.options.sort === true)
711 else if (Array.isArray(this.options.sort))
712 keys = this.options.sort;
714 if (this.options.widget == 'select') {
715 frameEl.appendChild(E('select', {
716 'id': this.options.id ? 'widget.' + this.options.id : null,
717 'name': this.options.name,
718 'size': this.options.size,
719 'class': 'cbi-input-select',
720 'multiple': this.options.multiple ? '' : null,
721 'disabled': this.options.disabled ? '' : null
724 if (this.options.optional)
725 frameEl.lastChild.appendChild(E('option', {
727 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
728 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
730 for (var i = 0; i < keys.length; i++) {
731 if (keys[i] == null || keys[i] == '')
734 frameEl.lastChild.appendChild(E('option', {
736 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
737 }, [ this.choices[keys[i]] || keys[i] ]));
741 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
743 for (var i = 0; i < keys.length; i++) {
744 frameEl.appendChild(E('label', {}, [
746 'id': this.options.id ? 'widget.' + this.options.id : null,
747 'name': this.options.id || this.options.name,
748 'type': this.options.multiple ? 'checkbox' : 'radio',
749 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
751 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
752 'disabled': this.options.disabled ? '' : null
754 this.choices[keys[i]] || keys[i]
757 if (i + 1 == this.options.size)
758 frameEl.appendChild(brEl);
762 return this.bind(frameEl);
766 bind: function(frameEl) {
769 if (this.options.widget == 'select') {
770 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
771 this.setChangeEvents(frameEl.firstChild, 'change');
774 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
775 for (var i = 0; i < radioEls.length; i++) {
776 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
777 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
781 dom.bindClassInstance(frameEl, this);
787 getValue: function() {
788 if (this.options.widget == 'select')
789 return this.node.firstChild.value;
791 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
792 for (var i = 0; i < radioEls.length; i++)
793 if (radioEls[i].checked)
794 return radioEls[i].value;
800 setValue: function(value) {
801 if (this.options.widget == 'select') {
805 for (var i = 0; i < this.node.firstChild.options.length; i++)
806 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
811 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
812 for (var i = 0; i < radioEls.length; i++)
813 radioEls[i].checked = (radioEls[i].value == value);
818 * Instantiate a rich dropdown choice widget.
820 * @constructor Dropdown
822 * @augments LuCI.ui.AbstractElement
826 * The `Dropdown` class implements a rich, stylable dropdown menu which
827 * supports non-text choice labels.
829 * UI widget instances are usually not supposed to be created by view code
830 * directly, instead they're implicitely created by `LuCI.form` when
831 * instantiating CBI forms.
833 * This class is automatically instantiated as part of `LuCI.ui`. To use it
834 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
835 * external JavaScript, use `L.require("ui").then(...)` and access the
836 * `Dropdown` property of the class instance value.
838 * @param {string|string[]} [value=null]
839 * The initial input value(s).
841 * @param {Object<string, *>} choices
842 * Object containing the selectable choices of the widget. The object keys
843 * serve as values for the different choices while the values are used as
846 * @param {LuCI.ui.Dropdown.InitOptions} [options]
847 * Object describing the widget specific options to initialize the dropdown.
849 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
851 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
852 * the following properties are recognized:
854 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
855 * @memberof LuCI.ui.Dropdown
857 * @property {boolean} [optional=true]
858 * Specifies whether the dropdown selection is optional. In contrast to
859 * other widgets, the `optional` constraint of dropdowns works differently;
860 * instead of marking the widget invalid on empty values when set to `false`,
861 * the user is not allowed to deselect all choices.
863 * For single value dropdowns that means that no empty "please select"
864 * choice is offered and for multi value dropdowns, the last selected choice
865 * may not be deselected without selecting another choice first.
867 * @property {boolean} [multiple]
868 * Specifies whether multiple choice values may be selected. It defaults
869 * to `true` when an array is passed as input value to the constructor.
871 * @property {boolean|string[]} [sort=false]
872 * Specifies if and how to sort choice values. If set to `true`, the choice
873 * values will be sorted alphabetically. If set to an array of strings, the
874 * choice sort order is derived from the array.
876 * @property {string} [select_placeholder=-- Please choose --]
877 * Specifies a placeholder text which is displayed when no choice is
880 * @property {string} [custom_placeholder=-- custom --]
881 * Specifies a placeholder text which is displayed in the text input
882 * field allowing to enter custom choice values. Only applicable if the
883 * `create` option is set to `true`.
885 * @property {boolean} [create=false]
886 * Specifies whether custom choices may be entered into the dropdown
889 * @property {string} [create_query=.create-item-input]
890 * Specifies a CSS selector expression used to find the input element
891 * which is used to enter custom choice values. This should not normally
892 * be used except by widgets derived from the Dropdown class.
894 * @property {string} [create_template=script[type="item-template"]]
895 * Specifies a CSS selector expression used to find an HTML element
896 * serving as template for newly added custom choice values.
898 * Any `{{value}}` placeholder string within the template elements text
899 * content will be replaced by the user supplied choice value, the
900 * resulting string is parsed as HTML and appended to the end of the
901 * choice list. The template markup may specify one HTML element with a
902 * `data-label-placeholder` attribute which is replaced by a matching
903 * label value from the `choices` object or with the user supplied value
904 * itself in case `choices` contains no matching choice label.
906 * If the template element is not found or if no `create_template` selector
907 * expression is specified, the default markup for newly created elements is
908 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
910 * @property {string} [create_markup]
911 * This property allows specifying the markup for custom choices directly
912 * instead of referring to a template element through CSS selectors.
914 * Apart from that it works exactly like `create_template`.
916 * @property {number} [display_items=3]
917 * Specifies the maximum amount of choice labels that should be shown in
918 * collapsed dropdown state before further selected choices are cut off.
920 * Only applicable when `multiple` is `true`.
922 * @property {number} [dropdown_items=-1]
923 * Specifies the maximum amount of choices that should be shown when the
924 * dropdown is open. If the amount of available choices exceeds this number,
925 * the dropdown area must be scrolled to reach further items.
927 * If set to `-1`, the dropdown menu will attempt to show all choice values
928 * and only resort to scrolling if the amount of choices exceeds the available
929 * screen space above and below the dropdown widget.
931 * @property {string} [placeholder]
932 * This property serves as a shortcut to set both `select_placeholder` and
933 * `custom_placeholder`. Either of these properties will fallback to
934 * `placeholder` if not specified.
936 * @property {boolean} [readonly=false]
937 * Specifies whether the custom choice input field should be rendered
938 * readonly. Only applicable when `create` is `true`.
940 * @property {number} [maxlength]
941 * Specifies the HTML `maxlength` attribute to set on the custom choice
942 * `<input>` element. Note that this a legacy property that exists for
943 * compatibility reasons. It is usually better to `maxlength(N)` validation
944 * expression. Only applicable when `create` is `true`.
946 __init__: function(value, choices, options) {
947 if (typeof(choices) != 'object')
950 if (!Array.isArray(value))
951 this.values = (value != null && value != '') ? [ value ] : [];
955 this.choices = choices;
956 this.options = Object.assign({
958 multiple: Array.isArray(value),
960 select_placeholder: _('-- Please choose --'),
961 custom_placeholder: _('-- custom --'),
965 create_query: '.create-item-input',
966 create_template: 'script[type="item-template"]'
973 'id': this.options.id,
974 'class': 'cbi-dropdown',
975 'multiple': this.options.multiple ? '' : null,
976 'optional': this.options.optional ? '' : null,
977 'disabled': this.options.disabled ? '' : null
980 var keys = Object.keys(this.choices);
982 if (this.options.sort === true)
984 else if (Array.isArray(this.options.sort))
985 keys = this.options.sort;
987 if (this.options.create)
988 for (var i = 0; i < this.values.length; i++)
989 if (!this.choices.hasOwnProperty(this.values[i]))
990 keys.push(this.values[i]);
992 for (var i = 0; i < keys.length; i++) {
993 var label = this.choices[keys[i]];
996 label = label.cloneNode(true);
998 sb.lastElementChild.appendChild(E('li', {
999 'data-value': keys[i],
1000 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
1001 }, [ label || keys[i] ]));
1004 if (this.options.create) {
1005 var createEl = E('input', {
1007 'class': 'create-item-input',
1008 'readonly': this.options.readonly ? '' : null,
1009 'maxlength': this.options.maxlength,
1010 'placeholder': this.options.custom_placeholder || this.options.placeholder
1013 if (this.options.datatype || this.options.validate)
1014 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1015 true, this.options.validate, 'blur', 'keyup');
1017 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1020 if (this.options.create_markup)
1021 sb.appendChild(E('script', { type: 'item-template' },
1022 this.options.create_markup));
1024 return this.bind(sb);
1028 bind: function(sb) {
1029 var o = this.options;
1031 o.multiple = sb.hasAttribute('multiple');
1032 o.optional = sb.hasAttribute('optional');
1033 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1034 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1035 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1036 o.create_query = sb.getAttribute('item-create') || o.create_query;
1037 o.create_template = sb.getAttribute('item-template') || o.create_template;
1039 var ul = sb.querySelector('ul'),
1040 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1041 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, 'â–¾')),
1042 canary = sb.appendChild(E('div')),
1043 create = sb.querySelector(this.options.create_query),
1044 ndisplay = this.options.display_items,
1047 if (this.options.multiple) {
1048 var items = ul.querySelectorAll('li');
1050 for (var i = 0; i < items.length; i++) {
1051 this.transformItem(sb, items[i]);
1053 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1054 items[i].setAttribute('display', n++);
1058 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1059 var placeholder = E('li', { placeholder: '' },
1060 this.options.select_placeholder || this.options.placeholder);
1063 ? ul.insertBefore(placeholder, ul.firstChild)
1064 : ul.appendChild(placeholder);
1067 var items = ul.querySelectorAll('li'),
1068 sel = sb.querySelectorAll('[selected]');
1070 sel.forEach(function(s) {
1071 s.removeAttribute('selected');
1074 var s = sel[0] || items[0];
1076 s.setAttribute('selected', '');
1077 s.setAttribute('display', n++);
1083 this.saveValues(sb, ul);
1085 ul.setAttribute('tabindex', -1);
1086 sb.setAttribute('tabindex', 0);
1089 sb.setAttribute('more', '')
1091 sb.removeAttribute('more');
1093 if (ndisplay == this.options.display_items)
1094 sb.setAttribute('empty', '')
1096 sb.removeAttribute('empty');
1098 dom.content(more, (ndisplay == this.options.display_items)
1099 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1102 sb.addEventListener('click', this.handleClick.bind(this));
1103 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1104 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1105 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1107 if ('ontouchstart' in window) {
1108 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1109 window.addEventListener('touchstart', this.closeAllDropdowns);
1112 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1113 sb.addEventListener('focus', this.handleFocus.bind(this));
1115 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1117 window.addEventListener('mouseover', this.setFocus);
1118 window.addEventListener('click', this.closeAllDropdowns);
1122 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1123 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1124 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1126 var li = findParent(create, 'li');
1128 li.setAttribute('unselectable', '');
1129 li.addEventListener('click', this.handleCreateClick.bind(this));
1134 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1135 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1137 dom.bindClassInstance(sb, this);
1143 openDropdown: function(sb) {
1144 var st = window.getComputedStyle(sb, null),
1145 ul = sb.querySelector('ul'),
1146 li = ul.querySelectorAll('li'),
1147 fl = findParent(sb, '.cbi-value-field'),
1148 sel = ul.querySelector('[selected]'),
1149 rect = sb.getBoundingClientRect(),
1150 items = Math.min(this.options.dropdown_items, li.length);
1152 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1153 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1156 sb.setAttribute('open', '');
1158 var pv = ul.cloneNode(true);
1159 pv.classList.add('preview');
1162 fl.classList.add('cbi-dropdown-open');
1164 if ('ontouchstart' in window) {
1165 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1166 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1169 ul.style.top = sb.offsetHeight + 'px';
1170 ul.style.left = -rect.left + 'px';
1171 ul.style.right = (rect.right - vpWidth) + 'px';
1172 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1173 ul.style.WebkitOverflowScrolling = 'touch';
1175 function getScrollParent(element) {
1176 var parent = element,
1177 style = getComputedStyle(element),
1178 excludeStaticParent = (style.position === 'absolute');
1180 if (style.position === 'fixed')
1181 return document.body;
1183 while ((parent = parent.parentElement) != null) {
1184 style = getComputedStyle(parent);
1186 if (excludeStaticParent && style.position === 'static')
1189 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1193 return document.body;
1196 var scrollParent = getScrollParent(sb),
1197 scrollFrom = scrollParent.scrollTop,
1198 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1200 var scrollStep = function(timestamp) {
1203 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1206 var duration = Math.max(timestamp - start, 1);
1207 if (duration < 100) {
1208 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1209 window.requestAnimationFrame(scrollStep);
1212 scrollParent.scrollTop = scrollTo;
1216 window.requestAnimationFrame(scrollStep);
1219 ul.style.maxHeight = '1px';
1220 ul.style.top = ul.style.bottom = '';
1222 window.requestAnimationFrame(function() {
1223 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1225 spaceAbove = rect.top,
1226 spaceBelow = window.innerHeight - rect.height - rect.top;
1228 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1229 fullHeight += li[i].getBoundingClientRect().height;
1231 if (fullHeight <= spaceBelow) {
1232 ul.style.top = rect.height + 'px';
1233 ul.style.maxHeight = spaceBelow + 'px';
1235 else if (fullHeight <= spaceAbove) {
1236 ul.style.bottom = rect.height + 'px';
1237 ul.style.maxHeight = spaceAbove + 'px';
1239 else if (spaceBelow >= spaceAbove) {
1240 ul.style.top = rect.height + 'px';
1241 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1244 ul.style.bottom = rect.height + 'px';
1245 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1248 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1252 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1253 for (var i = 0; i < cboxes.length; i++) {
1254 cboxes[i].checked = true;
1255 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1258 ul.classList.add('dropdown');
1260 sb.insertBefore(pv, ul.nextElementSibling);
1262 li.forEach(function(l) {
1263 l.setAttribute('tabindex', 0);
1266 sb.lastElementChild.setAttribute('tabindex', 0);
1268 this.setFocus(sb, sel || li[0], true);
1272 closeDropdown: function(sb, no_focus) {
1273 if (!sb.hasAttribute('open'))
1276 var pv = sb.querySelector('ul.preview'),
1277 ul = sb.querySelector('ul.dropdown'),
1278 li = ul.querySelectorAll('li'),
1279 fl = findParent(sb, '.cbi-value-field');
1281 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1282 sb.lastElementChild.removeAttribute('tabindex');
1285 sb.removeAttribute('open');
1286 sb.style.width = sb.style.height = '';
1288 ul.classList.remove('dropdown');
1289 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1292 fl.classList.remove('cbi-dropdown-open');
1295 this.setFocus(sb, sb);
1297 this.saveValues(sb, ul);
1301 toggleItem: function(sb, li, force_state) {
1302 if (li.hasAttribute('unselectable'))
1305 if (this.options.multiple) {
1306 var cbox = li.querySelector('input[type="checkbox"]'),
1307 items = li.parentNode.querySelectorAll('li'),
1308 label = sb.querySelector('ul.preview'),
1309 sel = li.parentNode.querySelectorAll('[selected]').length,
1310 more = sb.querySelector('.more'),
1311 ndisplay = this.options.display_items,
1314 if (li.hasAttribute('selected')) {
1315 if (force_state !== true) {
1316 if (sel > 1 || this.options.optional) {
1317 li.removeAttribute('selected');
1318 cbox.checked = cbox.disabled = false;
1322 cbox.disabled = true;
1327 if (force_state !== false) {
1328 li.setAttribute('selected', '');
1329 cbox.checked = true;
1330 cbox.disabled = false;
1335 while (label && label.firstElementChild)
1336 label.removeChild(label.firstElementChild);
1338 for (var i = 0; i < items.length; i++) {
1339 items[i].removeAttribute('display');
1340 if (items[i].hasAttribute('selected')) {
1341 if (ndisplay-- > 0) {
1342 items[i].setAttribute('display', n++);
1344 label.appendChild(items[i].cloneNode(true));
1346 var c = items[i].querySelector('input[type="checkbox"]');
1348 c.disabled = (sel == 1 && !this.options.optional);
1353 sb.setAttribute('more', '');
1355 sb.removeAttribute('more');
1357 if (ndisplay === this.options.display_items)
1358 sb.setAttribute('empty', '');
1360 sb.removeAttribute('empty');
1362 dom.content(more, (ndisplay === this.options.display_items)
1363 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1366 var sel = li.parentNode.querySelector('[selected]');
1368 sel.removeAttribute('display');
1369 sel.removeAttribute('selected');
1372 li.setAttribute('display', 0);
1373 li.setAttribute('selected', '');
1375 this.closeDropdown(sb, true);
1378 this.saveValues(sb, li.parentNode);
1382 transformItem: function(sb, li) {
1383 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1386 while (li.firstChild)
1387 label.appendChild(li.firstChild);
1389 li.appendChild(cbox);
1390 li.appendChild(label);
1394 saveValues: function(sb, ul) {
1395 var sel = ul.querySelectorAll('li[selected]'),
1396 div = sb.lastElementChild,
1397 name = this.options.name,
1401 while (div.lastElementChild)
1402 div.removeChild(div.lastElementChild);
1404 sel.forEach(function (s) {
1405 if (s.hasAttribute('placeholder'))
1410 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1414 div.appendChild(E('input', {
1422 strval += strval.length ? ' ' + v.value : v.value;
1430 if (this.options.multiple)
1431 detail.values = values;
1433 detail.value = values.length ? values[0] : null;
1437 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1444 setValues: function(sb, values) {
1445 var ul = sb.querySelector('ul');
1447 if (this.options.create) {
1448 for (var value in values) {
1449 this.createItems(sb, value);
1451 if (!this.options.multiple)
1456 if (this.options.multiple) {
1457 var lis = ul.querySelectorAll('li[data-value]');
1458 for (var i = 0; i < lis.length; i++) {
1459 var value = lis[i].getAttribute('data-value');
1460 if (values === null || !(value in values))
1461 this.toggleItem(sb, lis[i], false);
1463 this.toggleItem(sb, lis[i], true);
1467 var ph = ul.querySelector('li[placeholder]');
1469 this.toggleItem(sb, ph);
1471 var lis = ul.querySelectorAll('li[data-value]');
1472 for (var i = 0; i < lis.length; i++) {
1473 var value = lis[i].getAttribute('data-value');
1474 if (values !== null && (value in values))
1475 this.toggleItem(sb, lis[i]);
1481 setFocus: function(sb, elem, scroll) {
1482 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1485 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1488 document.querySelectorAll('.focus').forEach(function(e) {
1489 if (!matchesElem(e, 'input')) {
1490 e.classList.remove('focus');
1497 elem.classList.add('focus');
1500 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1505 createChoiceElement: function(sb, value, label) {
1506 var tpl = sb.querySelector(this.options.create_template),
1510 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1512 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1514 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1515 placeholder = new_item.querySelector('[data-label-placeholder]');
1518 var content = E('span', {}, label || this.choices[value] || [ value ]);
1520 while (content.firstChild)
1521 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1523 placeholder.parentNode.removeChild(placeholder);
1526 if (this.options.multiple)
1527 this.transformItem(sb, new_item);
1533 createItems: function(sb, value) {
1535 val = (value || '').trim(),
1536 ul = sb.querySelector('ul');
1538 if (!sbox.options.multiple)
1539 val = val.length ? [ val ] : [];
1541 val = val.length ? val.split(/\s+/) : [];
1543 val.forEach(function(item) {
1544 var new_item = null;
1546 ul.childNodes.forEach(function(li) {
1547 if (li.getAttribute && li.getAttribute('data-value') === item)
1552 new_item = sbox.createChoiceElement(sb, item);
1554 if (!sbox.options.multiple) {
1555 var old = ul.querySelector('li[created]');
1557 ul.removeChild(old);
1559 new_item.setAttribute('created', '');
1562 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1565 sbox.toggleItem(sb, new_item, true);
1566 sbox.setFocus(sb, new_item, true);
1571 * Remove all existing choices from the dropdown menu.
1573 * This function removes all preexisting dropdown choices from the widget,
1574 * keeping only choices currently being selected unless `reset_values` is
1575 * given, in which case all choices and deselected and removed.
1578 * @memberof LuCI.ui.Dropdown
1579 * @param {boolean} [reset_value=false]
1580 * If set to `true`, deselect and remove selected choices as well instead
1583 clearChoices: function(reset_value) {
1584 var ul = this.node.querySelector('ul'),
1585 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1586 len = lis.length - (this.options.create ? 1 : 0),
1587 val = reset_value ? null : this.getValue();
1589 for (var i = 0; i < len; i++) {
1590 var lival = lis[i].getAttribute('data-value');
1592 (!this.options.multiple && val != lival) ||
1593 (this.options.multiple && val.indexOf(lival) == -1))
1594 ul.removeChild(lis[i]);
1598 this.setValues(this.node, {});
1602 * Add new choices to the dropdown menu.
1604 * This function adds further choices to an existing dropdown menu,
1605 * ignoring choice values which are already present.
1608 * @memberof LuCI.ui.Dropdown
1609 * @param {string[]} values
1610 * The choice values to add to the dropdown widget.
1612 * @param {Object<string, *>} labels
1613 * The choice label values to use when adding dropdown choices. If no
1614 * label is found for a particular choice value, the value itself is used
1615 * as label text. Choice labels may be any valid value accepted by
1616 * {@link LuCI.dom#content}.
1618 addChoices: function(values, labels) {
1620 ul = sb.querySelector('ul'),
1621 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1623 if (!Array.isArray(values))
1624 values = L.toArray(values);
1626 if (!L.isObject(labels))
1629 for (var i = 0; i < values.length; i++) {
1632 for (var j = 0; j < lis.length; j++) {
1633 if (lis[j].getAttribute('data-value') === values[i]) {
1643 this.createChoiceElement(sb, values[i], labels[values[i]]),
1644 ul.lastElementChild);
1649 * Close all open dropdown widgets in the current document.
1651 closeAllDropdowns: function() {
1652 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1653 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1658 handleClick: function(ev) {
1659 var sb = ev.currentTarget;
1661 if (!sb.hasAttribute('open')) {
1662 if (!matchesElem(ev.target, 'input'))
1663 this.openDropdown(sb);
1666 var li = findParent(ev.target, 'li');
1667 if (li && li.parentNode.classList.contains('dropdown'))
1668 this.toggleItem(sb, li);
1669 else if (li && li.parentNode.classList.contains('preview'))
1670 this.closeDropdown(sb);
1671 else if (matchesElem(ev.target, 'span.open, span.more'))
1672 this.closeDropdown(sb);
1675 ev.preventDefault();
1676 ev.stopPropagation();
1680 handleKeydown: function(ev) {
1681 var sb = ev.currentTarget;
1683 if (matchesElem(ev.target, 'input'))
1686 if (!sb.hasAttribute('open')) {
1687 switch (ev.keyCode) {
1692 this.openDropdown(sb);
1693 ev.preventDefault();
1697 var active = findParent(document.activeElement, 'li');
1699 switch (ev.keyCode) {
1701 this.closeDropdown(sb);
1706 if (!active.hasAttribute('selected'))
1707 this.toggleItem(sb, active);
1708 this.closeDropdown(sb);
1709 ev.preventDefault();
1715 this.toggleItem(sb, active);
1716 ev.preventDefault();
1721 if (active && active.previousElementSibling) {
1722 this.setFocus(sb, active.previousElementSibling);
1723 ev.preventDefault();
1728 if (active && active.nextElementSibling) {
1729 this.setFocus(sb, active.nextElementSibling);
1730 ev.preventDefault();
1738 handleDropdownClose: function(ev) {
1739 var sb = ev.currentTarget;
1741 this.closeDropdown(sb, true);
1745 handleDropdownSelect: function(ev) {
1746 var sb = ev.currentTarget,
1747 li = findParent(ev.target, 'li');
1752 this.toggleItem(sb, li);
1753 this.closeDropdown(sb, true);
1757 handleMouseover: function(ev) {
1758 var sb = ev.currentTarget;
1760 if (!sb.hasAttribute('open'))
1763 var li = findParent(ev.target, 'li');
1765 if (li && li.parentNode.classList.contains('dropdown'))
1766 this.setFocus(sb, li);
1770 handleFocus: function(ev) {
1771 var sb = ev.currentTarget;
1773 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1774 if (s !== sb || sb.hasAttribute('open'))
1775 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1780 handleCanaryFocus: function(ev) {
1781 this.closeDropdown(ev.currentTarget.parentNode);
1785 handleCreateKeydown: function(ev) {
1786 var input = ev.currentTarget,
1787 sb = findParent(input, '.cbi-dropdown');
1789 switch (ev.keyCode) {
1791 ev.preventDefault();
1793 if (input.classList.contains('cbi-input-invalid'))
1796 this.createItems(sb, input.value);
1804 handleCreateFocus: function(ev) {
1805 var input = ev.currentTarget,
1806 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1807 sb = findParent(input, '.cbi-dropdown');
1810 cbox.checked = true;
1812 sb.setAttribute('locked-in', '');
1816 handleCreateBlur: function(ev) {
1817 var input = ev.currentTarget,
1818 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1819 sb = findParent(input, '.cbi-dropdown');
1822 cbox.checked = false;
1824 sb.removeAttribute('locked-in');
1828 handleCreateClick: function(ev) {
1829 ev.currentTarget.querySelector(this.options.create_query).focus();
1833 setValue: function(values) {
1834 if (this.options.multiple) {
1835 if (!Array.isArray(values))
1836 values = (values != null && values != '') ? [ values ] : [];
1840 for (var i = 0; i < values.length; i++)
1841 v[values[i]] = true;
1843 this.setValues(this.node, v);
1848 if (values != null) {
1849 if (Array.isArray(values))
1850 v[values[0]] = true;
1855 this.setValues(this.node, v);
1860 getValue: function() {
1861 var div = this.node.lastElementChild,
1862 h = div.querySelectorAll('input[type="hidden"]'),
1865 for (var i = 0; i < h.length; i++)
1868 return this.options.multiple ? v : v[0];
1873 * Instantiate a rich dropdown choice widget allowing custom values.
1875 * @constructor Combobox
1877 * @augments LuCI.ui.Dropdown
1881 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1882 * to enter custom values. Historically, comboboxes used to be a dedicated
1883 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1884 * with a set of enforced default properties for easier instantiation.
1886 * UI widget instances are usually not supposed to be created by view code
1887 * directly, instead they're implicitely created by `LuCI.form` when
1888 * instantiating CBI forms.
1890 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1891 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1892 * external JavaScript, use `L.require("ui").then(...)` and access the
1893 * `Combobox` property of the class instance value.
1895 * @param {string|string[]} [value=null]
1896 * The initial input value(s).
1898 * @param {Object<string, *>} choices
1899 * Object containing the selectable choices of the widget. The object keys
1900 * serve as values for the different choices while the values are used as
1903 * @param {LuCI.ui.Combobox.InitOptions} [options]
1904 * Object describing the widget specific options to initialize the dropdown.
1906 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1908 * Comboboxes support the same properties as
1909 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1910 * specific values for the following properties:
1912 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1913 * @memberof LuCI.ui.Combobox
1915 * @property {boolean} multiple=false
1916 * Since Comboboxes never allow selecting multiple values, this property
1917 * is forcibly set to `false`.
1919 * @property {boolean} create=true
1920 * Since Comboboxes always allow custom choice values, this property is
1921 * forcibly set to `true`.
1923 * @property {boolean} optional=true
1924 * Since Comboboxes are always optional, this property is forcibly set to
1927 __init__: function(value, choices, options) {
1928 this.super('__init__', [ value, choices, Object.assign({
1929 select_placeholder: _('-- Please choose --'),
1930 custom_placeholder: _('-- custom --'),
1942 * Instantiate a combo button widget offering multiple action choices.
1944 * @constructor ComboButton
1946 * @augments LuCI.ui.Dropdown
1950 * The `ComboButton` class implements a button element which can be expanded
1951 * into a dropdown to chose from a set of different action choices.
1953 * UI widget instances are usually not supposed to be created by view code
1954 * directly, instead they're implicitely created by `LuCI.form` when
1955 * instantiating CBI forms.
1957 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1958 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1959 * external JavaScript, use `L.require("ui").then(...)` and access the
1960 * `ComboButton` property of the class instance value.
1962 * @param {string|string[]} [value=null]
1963 * The initial input value(s).
1965 * @param {Object<string, *>} choices
1966 * Object containing the selectable choices of the widget. The object keys
1967 * serve as values for the different choices while the values are used as
1970 * @param {LuCI.ui.ComboButton.InitOptions} [options]
1971 * Object describing the widget specific options to initialize the button.
1973 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1975 * ComboButtons support the same properties as
1976 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1977 * specific values for some properties and add aditional button specific
1980 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1981 * @memberof LuCI.ui.ComboButton
1983 * @property {boolean} multiple=false
1984 * Since ComboButtons never allow selecting multiple actions, this property
1985 * is forcibly set to `false`.
1987 * @property {boolean} create=false
1988 * Since ComboButtons never allow creating custom choices, this property
1989 * is forcibly set to `false`.
1991 * @property {boolean} optional=false
1992 * Since ComboButtons must always select one action, this property is
1993 * forcibly set to `false`.
1995 * @property {Object<string, string>} [classes]
1996 * Specifies a mapping of choice values to CSS class names. If an action
1997 * choice is selected by the user and if a corresponding entry exists in
1998 * the `classes` object, the class names corresponding to the selected
1999 * value are set on the button element.
2001 * This is useful to apply different button styles, such as colors, to the
2002 * combined button depending on the selected action.
2004 * @property {function} [click]
2005 * Specifies a handler function to invoke when the user clicks the button.
2006 * This function will be called with the button DOM node as `this` context
2007 * and receive the DOM click event as first as well as the selected action
2008 * choice value as second argument.
2010 __init__: function(value, choices, options) {
2011 this.super('__init__', [ value, choices, Object.assign({
2021 render: function(/* ... */) {
2022 var node = UIDropdown.prototype.render.apply(this, arguments),
2023 val = this.getValue();
2025 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2026 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2032 handleClick: function(ev) {
2033 var sb = ev.currentTarget,
2036 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2037 return UIDropdown.prototype.handleClick.apply(this, arguments);
2039 if (this.options.click)
2040 return this.options.click.call(sb, ev, this.getValue());
2044 toggleItem: function(sb /*, ... */) {
2045 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2046 val = this.getValue();
2048 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2049 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2051 sb.setAttribute('class', 'cbi-dropdown');
2058 * Instantiate a dynamic list widget.
2060 * @constructor DynamicList
2062 * @augments LuCI.ui.AbstractElement
2066 * The `DynamicList` class implements a widget which allows the user to specify
2067 * an arbitrary amount of input values, either from free formed text input or
2068 * from a set of predefined choices.
2070 * UI widget instances are usually not supposed to be created by view code
2071 * directly, instead they're implicitely created by `LuCI.form` when
2072 * instantiating CBI forms.
2074 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2075 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2076 * external JavaScript, use `L.require("ui").then(...)` and access the
2077 * `DynamicList` property of the class instance value.
2079 * @param {string|string[]} [value=null]
2080 * The initial input value(s).
2082 * @param {Object<string, *>} [choices]
2083 * Object containing the selectable choices of the widget. The object keys
2084 * serve as values for the different choices while the values are used as
2085 * choice labels. If omitted, no default choices are presented to the user,
2086 * instead a plain text input field is rendered allowing the user to add
2087 * arbitrary values to the dynamic list.
2089 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2090 * Object describing the widget specific options to initialize the dynamic list.
2092 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2094 * In case choices are passed to the dynamic list contructor, the widget
2095 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2096 * but enforces specific values for some dropdown properties.
2098 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2099 * @memberof LuCI.ui.DynamicList
2101 * @property {boolean} multiple=false
2102 * Since dynamic lists never allow selecting multiple choices when adding
2103 * another list item, this property is forcibly set to `false`.
2105 * @property {boolean} optional=true
2106 * Since dynamic lists use an embedded dropdown to present a list of
2107 * predefined choice values, the dropdown must be made optional to allow
2108 * it to remain unselected.
2110 __init__: function(values, choices, options) {
2111 if (!Array.isArray(values))
2112 values = (values != null && values != '') ? [ values ] : [];
2114 if (typeof(choices) != 'object')
2117 this.values = values;
2118 this.choices = choices;
2119 this.options = Object.assign({}, options, {
2126 render: function() {
2128 'id': this.options.id,
2129 'class': 'cbi-dynlist',
2130 'disabled': this.options.disabled ? '' : null
2131 }, E('div', { 'class': 'add-item' }));
2134 if (this.options.placeholder != null)
2135 this.options.select_placeholder = this.options.placeholder;
2137 var cbox = new UICombobox(null, this.choices, this.options);
2139 dl.lastElementChild.appendChild(cbox.render());
2142 var inputEl = E('input', {
2143 'id': this.options.id ? 'widget.' + this.options.id : null,
2145 'class': 'cbi-input-text',
2146 'placeholder': this.options.placeholder,
2147 'disabled': this.options.disabled ? '' : null
2150 dl.lastElementChild.appendChild(inputEl);
2151 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2153 if (this.options.datatype || this.options.validate)
2154 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2155 true, this.options.validate, 'blur', 'keyup');
2158 for (var i = 0; i < this.values.length; i++) {
2159 var label = this.choices ? this.choices[this.values[i]] : null;
2161 if (dom.elem(label))
2162 label = label.cloneNode(true);
2164 this.addItem(dl, this.values[i], label);
2167 return this.bind(dl);
2171 bind: function(dl) {
2172 dl.addEventListener('click', L.bind(this.handleClick, this));
2173 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2174 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2178 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2179 this.setChangeEvents(dl, 'cbi-dynlist-change');
2181 dom.bindClassInstance(dl, this);
2187 addItem: function(dl, value, text, flash) {
2189 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2190 E('span', {}, [ text || value ]),
2193 'name': this.options.name,
2194 'value': value })]);
2196 dl.querySelectorAll('.item').forEach(function(item) {
2200 var hidden = item.querySelector('input[type="hidden"]');
2202 if (hidden && hidden.parentNode !== item)
2205 if (hidden && hidden.value === value)
2210 var ai = dl.querySelector('.add-item');
2211 ai.parentNode.insertBefore(new_item, ai);
2214 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2226 removeItem: function(dl, item) {
2227 var value = item.querySelector('input[type="hidden"]').value;
2228 var sb = dl.querySelector('.cbi-dropdown');
2230 sb.querySelectorAll('ul > li').forEach(function(li) {
2231 if (li.getAttribute('data-value') === value) {
2232 if (li.hasAttribute('dynlistcustom'))
2233 li.parentNode.removeChild(li);
2235 li.removeAttribute('unselectable');
2239 item.parentNode.removeChild(item);
2241 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2253 handleClick: function(ev) {
2254 var dl = ev.currentTarget,
2255 item = findParent(ev.target, '.item');
2257 if (this.options.disabled)
2261 this.removeItem(dl, item);
2263 else if (matchesElem(ev.target, '.cbi-button-add')) {
2264 var input = ev.target.previousElementSibling;
2265 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2266 this.addItem(dl, input.value, null, true);
2273 handleDropdownChange: function(ev) {
2274 var dl = ev.currentTarget,
2275 sbIn = ev.detail.instance,
2276 sbEl = ev.detail.element,
2277 sbVal = ev.detail.value;
2282 sbIn.setValues(sbEl, null);
2283 sbVal.element.setAttribute('unselectable', '');
2285 if (sbVal.element.hasAttribute('created')) {
2286 sbVal.element.removeAttribute('created');
2287 sbVal.element.setAttribute('dynlistcustom', '');
2290 var label = sbVal.text;
2292 if (sbVal.element) {
2295 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2296 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2299 this.addItem(dl, sbVal.value, label, true);
2303 handleKeydown: function(ev) {
2304 var dl = ev.currentTarget,
2305 item = findParent(ev.target, '.item');
2308 switch (ev.keyCode) {
2309 case 8: /* backspace */
2310 if (item.previousElementSibling)
2311 item.previousElementSibling.focus();
2313 this.removeItem(dl, item);
2316 case 46: /* delete */
2317 if (item.nextElementSibling) {
2318 if (item.nextElementSibling.classList.contains('item'))
2319 item.nextElementSibling.focus();
2321 item.nextElementSibling.firstElementChild.focus();
2324 this.removeItem(dl, item);
2328 else if (matchesElem(ev.target, '.cbi-input-text')) {
2329 switch (ev.keyCode) {
2330 case 13: /* enter */
2331 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2332 this.addItem(dl, ev.target.value, null, true);
2333 ev.target.value = '';
2338 ev.preventDefault();
2345 getValue: function() {
2346 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2347 input = this.node.querySelector('.add-item > input[type="text"]'),
2350 for (var i = 0; i < items.length; i++)
2351 v.push(items[i].value);
2353 if (input && input.value != null && input.value.match(/\S/) &&
2354 input.classList.contains('cbi-input-invalid') == false &&
2355 v.filter(function(s) { return s == input.value }).length == 0)
2356 v.push(input.value);
2362 setValue: function(values) {
2363 if (!Array.isArray(values))
2364 values = (values != null && values != '') ? [ values ] : [];
2366 var items = this.node.querySelectorAll('.item');
2368 for (var i = 0; i < items.length; i++)
2369 if (items[i].parentNode === this.node)
2370 this.removeItem(this.node, items[i]);
2372 for (var i = 0; i < values.length; i++)
2373 this.addItem(this.node, values[i],
2374 this.choices ? this.choices[values[i]] : null);
2378 * Add new suggested choices to the dynamic list.
2380 * This function adds further choices to an existing dynamic list,
2381 * ignoring choice values which are already present.
2384 * @memberof LuCI.ui.DynamicList
2385 * @param {string[]} values
2386 * The choice values to add to the dynamic lists suggestion dropdown.
2388 * @param {Object<string, *>} labels
2389 * The choice label values to use when adding suggested choices. If no
2390 * label is found for a particular choice value, the value itself is used
2391 * as label text. Choice labels may be any valid value accepted by
2392 * {@link LuCI.dom#content}.
2394 addChoices: function(values, labels) {
2395 var dl = this.node.lastElementChild.firstElementChild;
2396 dom.callClassMethod(dl, 'addChoices', values, labels);
2400 * Remove all existing choices from the dynamic list.
2402 * This function removes all preexisting suggested choices from the widget.
2405 * @memberof LuCI.ui.DynamicList
2407 clearChoices: function() {
2408 var dl = this.node.lastElementChild.firstElementChild;
2409 dom.callClassMethod(dl, 'clearChoices');
2414 * Instantiate a hidden input field widget.
2416 * @constructor Hiddenfield
2418 * @augments LuCI.ui.AbstractElement
2422 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2423 * which allows to store form data without exposing it to the user.
2425 * UI widget instances are usually not supposed to be created by view code
2426 * directly, instead they're implicitely created by `LuCI.form` when
2427 * instantiating CBI forms.
2429 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2430 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2431 * external JavaScript, use `L.require("ui").then(...)` and access the
2432 * `Hiddenfield` property of the class instance value.
2434 * @param {string|string[]} [value=null]
2435 * The initial input value.
2437 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2438 * Object describing the widget specific options to initialize the hidden input.
2440 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2441 __init__: function(value, options) {
2443 this.options = Object.assign({
2449 render: function() {
2450 var hiddenEl = E('input', {
2451 'id': this.options.id,
2456 return this.bind(hiddenEl);
2460 bind: function(hiddenEl) {
2461 this.node = hiddenEl;
2463 dom.bindClassInstance(hiddenEl, this);
2469 getValue: function() {
2470 return this.node.value;
2474 setValue: function(value) {
2475 this.node.value = value;
2480 * Instantiate a file upload widget.
2482 * @constructor FileUpload
2484 * @augments LuCI.ui.AbstractElement
2488 * The `FileUpload` class implements a widget which allows the user to upload,
2489 * browse, select and delete files beneath a predefined remote directory.
2491 * UI widget instances are usually not supposed to be created by view code
2492 * directly, instead they're implicitely created by `LuCI.form` when
2493 * instantiating CBI forms.
2495 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2496 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2497 * external JavaScript, use `L.require("ui").then(...)` and access the
2498 * `FileUpload` property of the class instance value.
2500 * @param {string|string[]} [value=null]
2501 * The initial input value.
2503 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2504 * Object describing the widget specific options to initialize the file
2507 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2509 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2510 * the following properties are recognized:
2512 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2513 * @memberof LuCI.ui.FileUpload
2515 * @property {boolean} [show_hidden=false]
2516 * Specifies whether hidden files should be displayed when browsing remote
2517 * files. Note that this is not a security feature, hidden files are always
2518 * present in the remote file listings received, this option merely controls
2519 * whether they're displayed or not.
2521 * @property {boolean} [enable_upload=true]
2522 * Specifies whether the widget allows the user to upload files. If set to
2523 * `false`, only existing files may be selected. Note that this is not a
2524 * security feature. Whether file upload requests are accepted remotely
2525 * depends on the ACL setup for the current session. This option merely
2526 * controls whether the upload controls are rendered or not.
2528 * @property {boolean} [enable_remove=true]
2529 * Specifies whether the widget allows the user to delete remove files.
2530 * If set to `false`, existing files may not be removed. Note that this is
2531 * not a security feature. Whether file delete requests are accepted
2532 * remotely depends on the ACL setup for the current session. This option
2533 * merely controls whether the file remove controls are rendered or not.
2535 * @property {string} [root_directory=/etc/luci-uploads]
2536 * Specifies the remote directory the upload and file browsing actions take
2537 * place in. Browsing to directories outside of the root directory is
2538 * prevented by the widget. Note that this is not a security feature.
2539 * Whether remote directories are browseable or not solely depends on the
2540 * ACL setup for the current session.
2542 __init__: function(value, options) {
2544 this.options = Object.assign({
2546 enable_upload: true,
2547 enable_remove: true,
2548 root_directory: '/etc/luci-uploads'
2553 bind: function(browserEl) {
2554 this.node = browserEl;
2556 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2557 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2559 dom.bindClassInstance(browserEl, this);
2565 render: function() {
2566 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2569 if (L.isObject(stat) && stat.type != 'directory')
2572 if (this.stat != null)
2573 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2574 else if (this.value != null)
2575 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2577 label = [ _('Select file…') ];
2579 return this.bind(E('div', { 'id': this.options.id }, [
2582 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2583 'disabled': this.options.disabled ? '' : null
2586 'class': 'cbi-filebrowser'
2590 'name': this.options.name,
2598 truncatePath: function(path) {
2599 if (path.length > 50)
2600 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2606 iconForType: function(type) {
2610 'src': L.resource('cbi/link.gif'),
2611 'title': _('Symbolic link'),
2617 'src': L.resource('cbi/folder.gif'),
2618 'title': _('Directory'),
2624 'src': L.resource('cbi/file.gif'),
2632 canonicalizePath: function(path) {
2633 return path.replace(/\/{2,}/, '/')
2634 .replace(/\/\.(\/|$)/g, '/')
2635 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2636 .replace(/\/$/, '');
2640 splitPath: function(path) {
2641 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2642 cpath = this.canonicalizePath(path || '/');
2644 if (cpath.length <= croot.length)
2647 if (cpath.charAt(croot.length) != '/')
2650 var parts = cpath.substring(croot.length + 1).split(/\//);
2652 parts.unshift(croot);
2658 handleUpload: function(path, list, ev) {
2659 var form = ev.target.parentNode,
2660 fileinput = form.querySelector('input[type="file"]'),
2661 nameinput = form.querySelector('input[type="text"]'),
2662 filename = (nameinput.value != null ? nameinput.value : '').trim();
2664 ev.preventDefault();
2666 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2669 var existing = list.filter(function(e) { return e.name == filename })[0];
2671 if (existing != null && existing.type == 'directory')
2672 return alert(_('A directory with the same name already exists.'));
2673 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2676 var data = new FormData();
2678 data.append('sessionid', L.env.sessionid);
2679 data.append('filename', path + '/' + filename);
2680 data.append('filedata', fileinput.files[0]);
2682 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2683 progress: L.bind(function(btn, ev) {
2684 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2686 }).then(L.bind(function(path, ev, res) {
2687 var reply = res.json();
2689 if (L.isObject(reply) && reply.failure)
2690 alert(_('Upload request failed: %s').format(reply.message));
2692 return this.handleSelect(path, null, ev);
2693 }, this, path, ev));
2697 handleDelete: function(path, fileStat, ev) {
2698 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2699 name = path.replace(/^.+\//, ''),
2702 ev.preventDefault();
2704 if (fileStat.type == 'directory')
2705 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2707 msg = _('Do you really want to delete "%s" ?').format(name);
2710 var button = this.node.firstElementChild,
2711 hidden = this.node.lastElementChild;
2713 if (path == hidden.value) {
2714 dom.content(button, _('Select file…'));
2718 return fs.remove(path).then(L.bind(function(parent, ev) {
2719 return this.handleSelect(parent, null, ev);
2720 }, this, parent, ev)).catch(function(err) {
2721 alert(_('Delete request failed: %s').format(err.message));
2727 renderUpload: function(path, list) {
2728 if (!this.options.enable_upload)
2734 'class': 'btn cbi-button-positive',
2735 'click': function(ev) {
2736 var uploadForm = ev.target.nextElementSibling,
2737 fileInput = uploadForm.querySelector('input[type="file"]');
2739 ev.target.style.display = 'none';
2740 uploadForm.style.display = '';
2743 }, _('Upload file…')),
2744 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2747 'style': 'display:none',
2748 'change': function(ev) {
2749 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2750 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2752 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2753 uploadbtn.disabled = false;
2758 'click': function(ev) {
2759 ev.preventDefault();
2760 ev.target.previousElementSibling.click();
2762 }, [ _('Browse…') ]),
2763 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2765 'class': 'btn cbi-button-save',
2766 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2768 }, [ _('Upload file') ])
2774 renderListing: function(container, path, list) {
2775 var breadcrumb = E('p'),
2778 list.sort(function(a, b) {
2779 var isDirA = (a.type == 'directory'),
2780 isDirB = (b.type == 'directory');
2782 if (isDirA != isDirB)
2783 return isDirA < isDirB;
2785 return a.name > b.name;
2788 for (var i = 0; i < list.length; i++) {
2789 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2792 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2793 selected = (entrypath == this.node.lastElementChild.value),
2794 mtime = new Date(list[i].mtime * 1000);
2796 rows.appendChild(E('li', [
2797 E('div', { 'class': 'name' }, [
2798 this.iconForType(list[i].type),
2802 'style': selected ? 'font-weight:bold' : null,
2803 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2804 entrypath, list[i].type != 'directory' ? list[i] : null)
2805 }, '%h'.format(list[i].name))
2807 E('div', { 'class': 'mtime hide-xs' }, [
2808 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2809 mtime.getFullYear(),
2810 mtime.getMonth() + 1,
2817 selected ? E('button', {
2819 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2820 }, [ _('Deselect') ]) : '',
2821 this.options.enable_remove ? E('button', {
2822 'class': 'btn cbi-button-negative',
2823 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2824 }, [ _('Delete') ]) : ''
2829 if (!rows.firstElementChild)
2830 rows.appendChild(E('em', _('No entries in this directory')));
2832 var dirs = this.splitPath(path),
2835 for (var i = 0; i < dirs.length; i++) {
2836 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2837 dom.append(breadcrumb, [
2841 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2842 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2846 dom.content(container, [
2849 E('div', { 'class': 'right' }, [
2850 this.renderUpload(path, list),
2854 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2861 handleCancel: function(ev) {
2862 var button = this.node.firstElementChild,
2863 browser = button.nextElementSibling;
2865 browser.classList.remove('open');
2866 button.style.display = '';
2868 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2870 ev.preventDefault();
2874 handleReset: function(ev) {
2875 var button = this.node.firstElementChild,
2876 hidden = this.node.lastElementChild;
2879 dom.content(button, _('Select file…'));
2881 this.handleCancel(ev);
2885 handleSelect: function(path, fileStat, ev) {
2886 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2887 ul = browser.querySelector('ul');
2889 if (fileStat == null) {
2890 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2891 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2894 var button = this.node.firstElementChild,
2895 hidden = this.node.lastElementChild;
2897 path = this.canonicalizePath(path);
2899 dom.content(button, [
2900 this.iconForType(fileStat.type),
2901 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2904 browser.classList.remove('open');
2905 button.style.display = '';
2906 hidden.value = path;
2908 this.stat = Object.assign({ path: path }, fileStat);
2909 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2914 handleFileBrowser: function(ev) {
2915 var button = ev.target,
2916 browser = button.nextElementSibling,
2917 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2919 if (path.indexOf(this.options.root_directory) != 0)
2920 path = this.options.root_directory;
2922 ev.preventDefault();
2924 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2925 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2926 dom.findClassInstance(browserEl).handleCancel(ev);
2929 button.style.display = 'none';
2930 browser.classList.add('open');
2932 return this.renderListing(browser, path, list);
2933 }, this, button, browser, path));
2937 getValue: function() {
2938 return this.node.lastElementChild.value;
2942 setValue: function(value) {
2943 this.node.lastElementChild.value = value;
2957 var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
2959 * @typedef {Object} MenuNode
2960 * @memberof LuCI.ui.menu
2962 * @property {string} name - The internal name of the node, as used in the URL
2963 * @property {number} order - The sort index of the menu node
2964 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
2965 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
2966 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
2967 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
2971 * Load and cache current menu tree.
2973 * @returns {Promise<LuCI.ui.menu.MenuNode>}
2974 * Returns a promise resolving to the root element of the menu tree.
2977 if (this.menu == null)
2978 this.menu = session.getLocalData('menu');
2980 if (!L.isObject(this.menu)) {
2981 this.menu = request.get(L.url('admin/menu')).then(L.bind(function(menu) {
2982 this.menu = menu.json();
2983 session.setLocalData('menu', this.menu);
2989 return Promise.resolve(this.menu);
2993 * @param {LuCI.ui.menu.MenuNode} [node]
2994 * The menu node to retrieve the children for. Defaults to the menu's
2995 * internal root node if omitted.
2997 * @returns {LuCI.ui.menu.MenuNode[]}
2998 * Returns an array of child menu nodes.
3000 getChildren: function(node) {
3006 for (var k in node.children) {
3007 if (!node.children.hasOwnProperty(k))
3010 if (!node.children[k].satisfied)
3013 if (!node.children[k].hasOwnProperty('title'))
3016 children.push(Object.assign(node.children[k], { name: k }));
3019 return children.sort(function(a, b) {
3020 return ((a.order || 1000) - (b.order || 1000));
3031 * Provides high level UI helper functionality.
3032 * To import the class in views, use `'require ui'`, to import it in
3033 * external JavaScript, use `L.require("ui").then(...)`.
3035 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
3036 __init__: function() {
3037 modalDiv = document.body.appendChild(
3038 dom.create('div', { id: 'modal_overlay' },
3039 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
3041 tooltipDiv = document.body.appendChild(
3042 dom.create('div', { class: 'cbi-tooltip' }));
3044 /* setup old aliases */
3045 L.showModal = this.showModal;
3046 L.hideModal = this.hideModal;
3047 L.showTooltip = this.showTooltip;
3048 L.hideTooltip = this.hideTooltip;
3049 L.itemlist = this.itemlist;
3051 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
3052 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
3053 document.addEventListener('focus', this.showTooltip.bind(this), true);
3054 document.addEventListener('blur', this.hideTooltip.bind(this), true);
3056 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
3057 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
3058 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
3062 * Display a modal overlay dialog with the specified contents.
3064 * The modal overlay dialog covers the current view preventing interaction
3065 * with the underlying view contents. Only one modal dialog instance can
3066 * be opened. Invoking showModal() while a modal dialog is already open will
3067 * replace the open dialog with a new one having the specified contents.
3069 * Additional CSS class names may be passed to influence the appearence of
3070 * the dialog. Valid values for the classes depend on the underlying theme.
3072 * @see LuCI.dom.content
3074 * @param {string} [title]
3075 * The title of the dialog. If `null`, no title element will be rendered.
3077 * @param {*} contents
3078 * The contents to add to the modal dialog. This should be a DOM node or
3079 * a document fragment in most cases. The value is passed as-is to the
3080 * `dom.content()` function - refer to its documentation for applicable
3083 * @param {...string} [classes]
3084 * A number of extra CSS class names which are set on the modal dialog
3088 * Returns a DOM Node representing the modal dialog element.
3090 showModal: function(title, children /* , ... */) {
3091 var dlg = modalDiv.firstElementChild;
3093 dlg.setAttribute('class', 'modal');
3095 for (var i = 2; i < arguments.length; i++)
3096 dlg.classList.add(arguments[i]);
3098 dom.content(dlg, dom.create('h4', {}, title));
3099 dom.append(dlg, children);
3101 document.body.classList.add('modal-overlay-active');
3107 * Close the open modal overlay dialog.
3109 * This function will close an open modal dialog and restore the normal view
3110 * behaviour. It has no effect if no modal dialog is currently open.
3112 * Note that this function is stand-alone, it does not rely on `this` and
3113 * will not invoke other class functions so it suitable to be used as event
3114 * handler as-is without the need to bind it first.
3116 hideModal: function() {
3117 document.body.classList.remove('modal-overlay-active');
3121 showTooltip: function(ev) {
3122 var target = findParent(ev.target, '[data-tooltip]');
3127 if (tooltipTimeout !== null) {
3128 window.clearTimeout(tooltipTimeout);
3129 tooltipTimeout = null;
3132 var rect = target.getBoundingClientRect(),
3133 x = rect.left + window.pageXOffset,
3134 y = rect.top + rect.height + window.pageYOffset;
3136 tooltipDiv.className = 'cbi-tooltip';
3137 tooltipDiv.innerHTML = 'â–² ';
3138 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3140 if (target.hasAttribute('data-tooltip-style'))
3141 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3143 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3144 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3145 tooltipDiv.firstChild.data = 'â–¼ ' + tooltipDiv.firstChild.data.substr(2);
3148 tooltipDiv.style.top = y + 'px';
3149 tooltipDiv.style.left = x + 'px';
3150 tooltipDiv.style.opacity = 1;
3152 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3154 detail: { target: target }
3159 hideTooltip: function(ev) {
3160 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3161 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3164 if (tooltipTimeout !== null) {
3165 window.clearTimeout(tooltipTimeout);
3166 tooltipTimeout = null;
3169 tooltipDiv.style.opacity = 0;
3170 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3172 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3176 * Add a notification banner at the top of the current view.
3178 * A notification banner is an alert message usually displayed at the
3179 * top of the current view, spanning the entire availibe width.
3180 * Notification banners will stay in place until dismissed by the user.
3181 * Multiple banners may be shown at the same time.
3183 * Additional CSS class names may be passed to influence the appearence of
3184 * the banner. Valid values for the classes depend on the underlying theme.
3186 * @see LuCI.dom.content
3188 * @param {string} [title]
3189 * The title of the notification banner. If `null`, no title element
3192 * @param {*} contents
3193 * The contents to add to the notification banner. This should be a DOM
3194 * node or a document fragment in most cases. The value is passed as-is
3195 * to the `dom.content()` function - refer to its documentation for
3196 * applicable values.
3198 * @param {...string} [classes]
3199 * A number of extra CSS class names which are set on the notification
3203 * Returns a DOM Node representing the notification banner element.
3205 addNotification: function(title, children /*, ... */) {
3206 var mc = document.querySelector('#maincontent') || document.body;
3207 var msg = E('div', {
3208 'class': 'alert-message fade-in',
3209 'style': 'display:flex',
3210 'transitionend': function(ev) {
3211 var node = ev.currentTarget;
3212 if (node.parentNode && node.classList.contains('fade-out'))
3213 node.parentNode.removeChild(node);
3216 E('div', { 'style': 'flex:10' }),
3217 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3220 'style': 'margin-left:auto; margin-top:auto',
3221 'click': function(ev) {
3222 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3225 }, [ _('Dismiss') ])
3230 dom.append(msg.firstElementChild, E('h4', {}, title));
3232 dom.append(msg.firstElementChild, children);
3234 for (var i = 2; i < arguments.length; i++)
3235 msg.classList.add(arguments[i]);
3237 mc.insertBefore(msg, mc.firstElementChild);
3243 * Display or update an header area indicator.
3245 * An indicator is a small label displayed in the header area of the screen
3246 * providing few amounts of status information such as item counts or state
3247 * toggle indicators.
3249 * Multiple indicators may be shown at the same time and indicator labels
3250 * may be made clickable to display extended information or to initiate
3253 * Indicators can either use a default `active` or a less accented `inactive`
3254 * style which is useful for indicators representing state toggles.
3256 * @param {string} id
3257 * The ID of the indicator. If an indicator with the given ID already exists,
3258 * it is updated with the given label and style.
3260 * @param {string} label
3261 * The text to display in the indicator label.
3263 * @param {function} [handler]
3264 * A handler function to invoke when the indicator label is clicked/touched
3265 * by the user. If omitted, the indicator is not clickable/touchable.
3267 * Note that this parameter only applies to new indicators, when updating
3268 * existing labels it is ignored.
3270 * @param {string} [style=active]
3271 * The indicator style to use. May be either `active` or `inactive`.
3273 * @returns {boolean}
3274 * Returns `true` when the indicator has been updated or `false` when no
3275 * changes were made.
3277 showIndicator: function(id, label, handler, style) {
3278 if (indicatorDiv == null) {
3279 indicatorDiv = document.body.querySelector('#indicators');
3281 if (indicatorDiv == null)
3285 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3286 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) ||
3287 indicatorDiv.appendChild(E('span', {
3288 'data-indicator': id,
3289 'data-clickable': handlerFn ? true : null,
3293 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3296 indicatorElem.firstChild.data = label;
3297 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3302 * Remove an header area indicator.
3304 * This function removes the given indicator label from the header indicator
3305 * area. When the given indicator is not found, this function does nothing.
3307 * @param {string} id
3308 * The ID of the indicator to remove.
3310 * @returns {boolean}
3311 * Returns `true` when the indicator has been removed or `false` when the
3312 * requested indicator was not found.
3314 hideIndicator: function(id) {
3315 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3317 if (indicatorElem == null)
3320 indicatorDiv.removeChild(indicatorElem);
3325 * Formats a series of label/value pairs into list-like markup.
3327 * This function transforms a flat array of alternating label and value
3328 * elements into a list-like markup, using the values in `separators` as
3329 * separators and appends the resulting nodes to the given parent DOM node.
3331 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3332 * `<strong>` element and the value corresponding to the label are
3333 * subsequently wrapped into a `<span class="nowrap">` element.
3335 * The resulting `<span>` element tuples are joined by the given separators
3336 * to form the final markup which is appened to the given parent DOM node.
3338 * @param {Node} node
3339 * The parent DOM node to append the markup to. Any previous child elements
3342 * @param {Array<*>} items
3343 * An alternating array of labels and values. The label values will be
3344 * converted to plain strings, the values are used as-is and may be of
3345 * any type accepted by `LuCI.dom.content()`.
3347 * @param {*|Array<*>} [separators=[E('br')]]
3348 * A single value or an array of separator values to separate each
3349 * label/value pair with. The function will cycle through the separators
3350 * when joining the pairs. If omitted, the default separator is a sole HTML
3351 * `<br>` element. Separator values are used as-is and may be of any type
3352 * accepted by `LuCI.dom.content()`.
3355 * Returns the parent DOM node the formatted markup has been added to.
3357 itemlist: function(node, items, separators) {
3360 if (!Array.isArray(separators))
3361 separators = [ separators || E('br') ];
3363 for (var i = 0; i < items.length; i += 2) {
3364 if (items[i+1] !== null && items[i+1] !== undefined) {
3365 var sep = separators[(i/2) % separators.length],
3368 children.push(E('span', { class: 'nowrap' }, [
3369 items[i] ? E('strong', items[i] + ': ') : '',
3373 if ((i+2) < items.length)
3374 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3378 dom.content(node, children);
3389 * The `tabs` class handles tab menu groups used throughout the view area.
3390 * It takes care of setting up tab groups, tracking their state and handling
3393 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3394 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3395 * external JavaScript, use `L.require("ui").then(...)` and access the
3396 * `tabs` property of the class instance value.
3398 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3401 var groups = [], prevGroup = null, currGroup = null;
3403 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3404 var parent = tab.parentNode;
3406 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3409 if (!parent.hasAttribute('data-tab-group'))
3410 parent.setAttribute('data-tab-group', groups.length);
3412 currGroup = +parent.getAttribute('data-tab-group');
3414 if (currGroup !== prevGroup) {
3415 prevGroup = currGroup;
3417 if (!groups[currGroup])
3418 groups[currGroup] = [];
3421 groups[currGroup].push(tab);
3424 for (var i = 0; i < groups.length; i++)
3425 this.initTabGroup(groups[i]);
3427 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3433 * Initializes a new tab group from the given tab pane collection.
3435 * This function cycles through the given tab pane DOM nodes, extracts
3436 * their tab IDs, titles and active states, renders a corresponding
3437 * tab menu and prepends it to the tab panes common parent DOM node.
3439 * The tab menu labels will be set to the value of the `data-tab-title`
3440 * attribute of each corresponding pane. The last pane with the
3441 * `data-tab-active` attribute set to `true` will be selected by default.
3443 * If no pane is marked as active, the first one will be preselected.
3446 * @memberof LuCI.ui.tabs
3447 * @param {Array<Node>|NodeList} panes
3448 * A collection of tab panes to build a tab group menu for. May be a
3449 * plain array of DOM nodes or a NodeList collection, such as the result
3450 * of a `querySelectorAll()` call or the `.childNodes` property of a
3453 initTabGroup: function(panes) {
3454 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3457 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3458 group = panes[0].parentNode,
3459 groupId = +group.getAttribute('data-tab-group'),
3462 if (group.getAttribute('data-initialized') === 'true')
3465 for (var i = 0, pane; pane = panes[i]; i++) {
3466 var name = pane.getAttribute('data-tab'),
3467 title = pane.getAttribute('data-tab-title'),
3468 active = pane.getAttribute('data-tab-active') === 'true';
3470 menu.appendChild(E('li', {
3471 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3472 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3476 'click': this.switchTab.bind(this)
3483 group.parentNode.insertBefore(menu, group);
3484 group.setAttribute('data-initialized', true);
3486 if (selected === null) {
3487 selected = this.getActiveTabId(panes[0]);
3489 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3490 for (var i = 0; i < panes.length; i++) {
3491 if (!this.isEmptyPane(panes[i])) {
3498 menu.childNodes[selected].classList.add('cbi-tab');
3499 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3500 panes[selected].setAttribute('data-tab-active', 'true');
3502 this.setActiveTabId(panes[selected], selected);
3505 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3506 detail: { tab: panes[selected].getAttribute('data-tab') }
3509 this.updateTabs(group);
3513 * Checks whether the given tab pane node is empty.
3516 * @memberof LuCI.ui.tabs
3517 * @param {Node} pane
3518 * The tab pane to check.
3520 * @returns {boolean}
3521 * Returns `true` if the pane is empty, else `false`.
3523 isEmptyPane: function(pane) {
3524 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3528 getPathForPane: function(pane) {
3529 var path = [], node = null;
3531 for (node = pane ? pane.parentNode : null;
3532 node != null && node.hasAttribute != null;
3533 node = node.parentNode)
3535 if (node.hasAttribute('data-tab'))
3536 path.unshift(node.getAttribute('data-tab'));
3537 else if (node.hasAttribute('data-section-id'))
3538 path.unshift(node.getAttribute('data-section-id'));
3541 return path.join('/');
3545 getActiveTabState: function() {
3546 var page = document.body.getAttribute('data-page'),
3547 state = session.getLocalData('tab');
3549 if (L.isObject(state) && state.page === page && L.isObject(state.paths))
3552 session.setLocalData('tab', null);
3554 return { page: page, paths: {} };
3558 getActiveTabId: function(pane) {
3559 var path = this.getPathForPane(pane);
3560 return +this.getActiveTabState().paths[path] || 0;
3564 setActiveTabId: function(pane, tabIndex) {
3565 var path = this.getPathForPane(pane),
3566 state = this.getActiveTabState();
3568 state.paths[path] = tabIndex;
3570 return session.setLocalData('tab', state);
3574 updateTabs: function(ev, root) {
3575 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3576 var menu = pane.parentNode.previousElementSibling,
3577 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3578 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3583 if (this.isEmptyPane(pane)) {
3584 tab.style.display = 'none';
3585 tab.classList.remove('flash');
3587 else if (tab.style.display === 'none') {
3588 tab.style.display = '';
3589 requestAnimationFrame(function() { tab.classList.add('flash') });
3593 tab.setAttribute('data-errors', n_errors);
3594 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3595 tab.setAttribute('data-tooltip-style', 'error');
3598 tab.removeAttribute('data-errors');
3599 tab.removeAttribute('data-tooltip');
3605 switchTab: function(ev) {
3606 var tab = ev.target.parentNode,
3607 name = tab.getAttribute('data-tab'),
3608 menu = tab.parentNode,
3609 group = menu.nextElementSibling,
3610 groupId = +group.getAttribute('data-tab-group'),
3613 ev.preventDefault();
3615 if (!tab.classList.contains('cbi-tab-disabled'))
3618 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3619 tab.classList.remove('cbi-tab');
3620 tab.classList.remove('cbi-tab-disabled');
3622 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3625 group.childNodes.forEach(function(pane) {
3626 if (dom.matches(pane, '[data-tab]')) {
3627 if (pane.getAttribute('data-tab') === name) {
3628 pane.setAttribute('data-tab-active', 'true');
3629 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3630 UI.prototype.tabs.setActiveTabId(pane, index);
3633 pane.setAttribute('data-tab-active', 'false');
3643 * @typedef {Object} FileUploadReply
3646 * @property {string} name - Name of the uploaded file without directory components
3647 * @property {number} size - Size of the uploaded file in bytes
3648 * @property {string} checksum - The MD5 checksum of the received file data
3649 * @property {string} sha256sum - The SHA256 checksum of the received file data
3653 * Display a modal file upload prompt.
3655 * This function opens a modal dialog prompting the user to select and
3656 * upload a file to a predefined remote destination path.
3658 * @param {string} path
3659 * The remote file path to upload the local file to.
3661 * @param {Node} [progessStatusNode]
3662 * An optional DOM text node whose content text is set to the progress
3663 * percentage value during file upload.
3665 * @returns {Promise<LuCI.ui.FileUploadReply>}
3666 * Returns a promise resolving to a file upload status object on success
3667 * or rejecting with an error in case the upload failed or has been
3668 * cancelled by the user.
3670 uploadFile: function(path, progressStatusNode) {
3671 return new Promise(function(resolveFn, rejectFn) {
3672 UI.prototype.showModal(_('Uploading file…'), [
3673 E('p', _('Please select the file to upload.')),
3674 E('div', { 'style': 'display:flex' }, [
3675 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3678 style: 'display:none',
3679 change: function(ev) {
3680 var modal = dom.parent(ev.target, '.modal'),
3681 body = modal.querySelector('p'),
3682 upload = modal.querySelector('.cbi-button-action.important'),
3683 file = ev.currentTarget.files[0];
3690 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3691 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3695 upload.disabled = false;
3701 'click': function(ev) {
3702 ev.target.previousElementSibling.click();
3704 }, [ _('Browse…') ])
3706 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3709 'click': function() {
3710 UI.prototype.hideModal();
3711 rejectFn(new Error('Upload has been cancelled'));
3713 }, [ _('Cancel') ]),
3716 'class': 'btn cbi-button-action important',
3718 'click': function(ev) {
3719 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3721 if (!input.files[0])
3724 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3726 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3728 var data = new FormData();
3730 data.append('sessionid', rpc.getSessionID());
3731 data.append('filename', path);
3732 data.append('filedata', input.files[0]);
3734 var filename = input.files[0].name;
3736 request.post(L.env.cgi_base + '/cgi-upload', data, {
3738 progress: function(pev) {
3739 var percent = (pev.loaded / pev.total) * 100;
3741 if (progressStatusNode)
3742 progressStatusNode.data = '%.2f%%'.format(percent);
3744 progress.setAttribute('title', '%.2f%%'.format(percent));
3745 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3747 }).then(function(res) {
3748 var reply = res.json();
3750 UI.prototype.hideModal();
3752 if (L.isObject(reply) && reply.failure) {
3753 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3754 rejectFn(new Error(reply.failure));
3757 reply.name = filename;
3761 UI.prototype.hideModal();
3773 * Perform a device connectivity test.
3775 * Attempt to fetch a well known ressource from the remote device via HTTP
3776 * in order to test connectivity. This function is mainly useful to wait
3777 * for the router to come back online after a reboot or reconfiguration.
3779 * @param {string} [proto=http]
3780 * The protocol to use for fetching the resource. May be either `http`
3781 * (the default) or `https`.
3783 * @param {string} [host=window.location.host]
3784 * Override the host address to probe. By default the current host as seen
3785 * in the address bar is probed.
3787 * @returns {Promise<Event>}
3788 * Returns a promise resolving to a `load` event in case the device is
3789 * reachable or rejecting with an `error` event in case it is not reachable
3790 * or rejecting with `null` when the connectivity check timed out.
3792 pingDevice: function(proto, ipaddr) {
3793 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3795 return new Promise(function(resolveFn, rejectFn) {
3796 var img = new Image();
3798 img.onload = resolveFn;
3799 img.onerror = rejectFn;
3801 window.setTimeout(rejectFn, 1000);
3808 * Wait for device to come back online and reconnect to it.
3810 * Poll each given hostname or IP address and navigate to it as soon as
3811 * one of the addresses becomes reachable.
3813 * @param {...string} [hosts=[window.location.host]]
3814 * The list of IP addresses and host names to check for reachability.
3815 * If omitted, the current value of `window.location.host` is used by
3818 awaitReconnect: function(/* ... */) {
3819 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3821 window.setTimeout(L.bind(function() {
3822 poll.add(L.bind(function() {
3823 var tasks = [], reachable = false;
3825 for (var i = 0; i < 2; i++)
3826 for (var j = 0; j < ipaddrs.length; j++)
3827 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3828 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3830 return Promise.all(tasks).then(function() {
3833 window.location = reachable;
3846 * The `changes` class encapsulates logic for visualizing, applying,
3847 * confirming and reverting staged UCI changesets.
3849 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3850 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3851 * external JavaScript, use `L.require("ui").then(...)` and access the
3852 * `changes` property of the class instance value.
3854 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3856 if (!L.env.sessionid)
3859 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3863 * Set the change count indicator.
3865 * This function updates or hides the UCI change count indicator,
3866 * depending on the passed change count. When the count is greater
3867 * than 0, the change indicator is displayed or updated, otherwise it
3871 * @memberof LuCI.ui.changes
3872 * @param {number} numChanges
3873 * The number of changes to indicate.
3875 setIndicator: function(n) {
3877 UI.prototype.showIndicator('uci-changes',
3878 '%s: %d'.format(_('Unsaved Changes'), n),
3879 L.bind(this.displayChanges, this));
3882 UI.prototype.hideIndicator('uci-changes');
3887 * Update the change count indicator.
3889 * This function updates the UCI change count indicator from the given
3890 * UCI changeset structure.
3893 * @memberof LuCI.ui.changes
3894 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3895 * The UCI changeset to count.
3897 renderChangeIndicator: function(changes) {
3900 for (var config in changes)
3901 if (changes.hasOwnProperty(config))
3902 n_changes += changes[config].length;
3904 this.changes = changes;
3905 this.setIndicator(n_changes);
3910 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3911 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3912 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3913 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
3914 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3915 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3916 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3917 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3918 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
3919 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3923 * Display the current changelog.
3925 * Open a modal dialog visualizing the currently staged UCI changes
3926 * and offer options to revert or apply the shown changes.
3929 * @memberof LuCI.ui.changes
3931 displayChanges: function() {
3932 var list = E('div', { 'class': 'uci-change-list' }),
3933 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3934 E('div', { 'class': 'cbi-section' }, [
3935 E('strong', _('Legend:')),
3936 E('div', { 'class': 'uci-change-legend' }, [
3937 E('div', { 'class': 'uci-change-legend-label' }, [
3938 E('ins', ' '), ' ', _('Section added') ]),
3939 E('div', { 'class': 'uci-change-legend-label' }, [
3940 E('del', ' '), ' ', _('Section removed') ]),
3941 E('div', { 'class': 'uci-change-legend-label' }, [
3942 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
3943 E('div', { 'class': 'uci-change-legend-label' }, [
3944 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
3946 E('div', { 'class': 'right' }, [
3949 'click': UI.prototype.hideModal
3950 }, [ _('Dismiss') ]), ' ',
3952 'class': 'cbi-button cbi-button-positive important',
3953 'click': L.bind(this.apply, this, true)
3954 }, [ _('Save & Apply') ]), ' ',
3956 'class': 'cbi-button cbi-button-reset',
3957 'click': L.bind(this.revert, this)
3958 }, [ _('Revert') ])])])
3961 for (var config in this.changes) {
3962 if (!this.changes.hasOwnProperty(config))
3965 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
3967 for (var i = 0, added = null; i < this.changes[config].length; i++) {
3968 var chg = this.changes[config][i],
3969 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
3971 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
3977 if (added != null && chg[1] == added[0])
3978 return '@' + added[1] + '[-1]';
3983 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
3990 if (chg[0] == 'add')
3991 added = [ chg[1], chg[2] ];
3995 list.appendChild(E('br'));
3996 dlg.classList.add('uci-dialog');
4000 displayStatus: function(type, content) {
4002 var message = UI.prototype.showModal('', '');
4004 message.classList.add('alert-message');
4005 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4008 dom.content(message, content);
4010 if (!this.was_polling) {
4011 this.was_polling = request.poll.active();
4012 request.poll.stop();
4016 UI.prototype.hideModal();
4018 if (this.was_polling)
4019 request.poll.start();
4024 rollback: function(checked) {
4026 this.displayStatus('warning spinning',
4027 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4028 .format(L.env.apply_rollback)));
4030 var call = function(r, data, duration) {
4031 if (r.status === 204) {
4032 UI.prototype.changes.displayStatus('warning', [
4033 E('h4', _('Configuration changes have been rolled back!')),
4034 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)),
4035 E('div', { 'class': 'right' }, [
4038 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4039 }, [ _('Dismiss') ]), ' ',
4041 'class': 'btn cbi-button-action important',
4042 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4043 }, [ _('Revert changes') ]), ' ',
4045 'class': 'btn cbi-button-negative important',
4046 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4047 }, [ _('Apply unchecked') ])
4054 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4055 window.setTimeout(function() {
4056 request.request(L.url('admin/uci/confirm'), {
4058 timeout: L.env.apply_timeout * 1000,
4059 query: { sid: L.env.sessionid, token: L.env.token }
4064 call({ status: 0 });
4067 this.displayStatus('warning', [
4068 E('h4', _('Device unreachable!')),
4069 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.'))
4075 confirm: function(checked, deadline, override_token) {
4077 var ts = Date.now();
4079 this.displayStatus('notice');
4082 this.confirm_auth = { token: override_token };
4084 var call = function(r, data, duration) {
4085 if (Date.now() >= deadline) {
4086 window.clearTimeout(tt);
4087 UI.prototype.changes.rollback(checked);
4090 else if (r && (r.status === 200 || r.status === 204)) {
4091 document.dispatchEvent(new CustomEvent('uci-applied'));
4093 UI.prototype.changes.setIndicator(0);
4094 UI.prototype.changes.displayStatus('notice',
4095 E('p', _('Configuration changes applied.')));
4097 window.clearTimeout(tt);
4098 window.setTimeout(function() {
4099 //UI.prototype.changes.displayStatus(false);
4100 window.location = window.location.href.split('#')[0];
4101 }, L.env.apply_display * 1000);
4106 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4107 window.setTimeout(function() {
4108 request.request(L.url('admin/uci/confirm'), {
4110 timeout: L.env.apply_timeout * 1000,
4111 query: UI.prototype.changes.confirm_auth
4112 }).then(call, call);
4116 var tick = function() {
4117 var now = Date.now();
4119 UI.prototype.changes.displayStatus('notice spinning',
4120 E('p', _('Applying configuration changes… %ds')
4121 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4123 if (now >= deadline)
4126 tt = window.setTimeout(tick, 1000 - (now - ts));
4132 /* wait a few seconds for the settings to become effective */
4133 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4137 * Apply the staged configuration changes.
4139 * Start applying staged configuration changes and open a modal dialog
4140 * with a progress indication to prevent interaction with the view
4141 * during the apply process. The modal dialog will be automatically
4142 * closed and the current view reloaded once the apply process is
4146 * @memberof LuCI.ui.changes
4147 * @param {boolean} [checked=false]
4148 * Whether to perform a checked (`true`) configuration apply or an
4149 * unchecked (`false`) one.
4151 * In case of a checked apply, the configuration changes must be
4152 * confirmed within a specific time interval, otherwise the device
4153 * will begin to roll back the changes in order to restore the previous
4156 apply: function(checked) {
4157 this.displayStatus('notice spinning',
4158 E('p', _('Starting configuration apply…')));
4160 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4162 query: { sid: L.env.sessionid, token: L.env.token }
4163 }).then(function(r) {
4164 if (r.status === (checked ? 200 : 204)) {
4165 var tok = null; try { tok = r.json(); } catch(e) {}
4166 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4167 UI.prototype.changes.confirm_auth = tok;
4169 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4171 else if (checked && r.status === 204) {
4172 UI.prototype.changes.displayStatus('notice',
4173 E('p', _('There are no changes to apply')));
4175 window.setTimeout(function() {
4176 UI.prototype.changes.displayStatus(false);
4177 }, L.env.apply_display * 1000);
4180 UI.prototype.changes.displayStatus('warning',
4181 E('p', _('Apply request failed with status <code>%h</code>')
4182 .format(r.responseText || r.statusText || r.status)));
4184 window.setTimeout(function() {
4185 UI.prototype.changes.displayStatus(false);
4186 }, L.env.apply_display * 1000);
4192 * Revert the staged configuration changes.
4194 * Start reverting staged configuration changes and open a modal dialog
4195 * with a progress indication to prevent interaction with the view
4196 * during the revert process. The modal dialog will be automatically
4197 * closed and the current view reloaded once the revert process is
4201 * @memberof LuCI.ui.changes
4203 revert: function() {
4204 this.displayStatus('notice spinning',
4205 E('p', _('Reverting configuration…')));
4207 request.request(L.url('admin/uci/revert'), {
4209 query: { sid: L.env.sessionid, token: L.env.token }
4210 }).then(function(r) {
4211 if (r.status === 200) {
4212 document.dispatchEvent(new CustomEvent('uci-reverted'));
4214 UI.prototype.changes.setIndicator(0);
4215 UI.prototype.changes.displayStatus('notice',
4216 E('p', _('Changes have been reverted.')));
4218 window.setTimeout(function() {
4219 //UI.prototype.changes.displayStatus(false);
4220 window.location = window.location.href.split('#')[0];
4221 }, L.env.apply_display * 1000);
4224 UI.prototype.changes.displayStatus('warning',
4225 E('p', _('Revert request failed with status <code>%h</code>')
4226 .format(r.statusText || r.status)));
4228 window.setTimeout(function() {
4229 UI.prototype.changes.displayStatus(false);
4230 }, L.env.apply_display * 1000);
4237 * Add validation constraints to an input element.
4239 * Compile the given type expression and optional validator function into
4240 * a validation function and bind it to the specified input element events.
4242 * @param {Node} field
4243 * The DOM input element node to bind the validation constraints to.
4245 * @param {string} type
4246 * The datatype specification to describe validation constraints.
4247 * Refer to the `LuCI.validation` class documentation for details.
4249 * @param {boolean} [optional=false]
4250 * Specifies whether empty values are allowed (`true`) or not (`false`).
4251 * If an input element is not marked optional it must not be empty,
4252 * otherwise it will be marked as invalid.
4254 * @param {function} [vfunc]
4255 * Specifies a custom validation function which is invoked after the
4256 * other validation constraints are applied. The validation must return
4257 * `true` to accept the passed value. Any other return type is converted
4258 * to a string and treated as validation error message.
4260 * @param {...string} [events=blur, keyup]
4261 * The list of events to bind. Each received event will trigger a field
4262 * validation. If omitted, the `keyup` and `blur` events are bound by
4265 * @returns {function}
4266 * Returns the compiled validator function which can be used to manually
4267 * trigger field validation or to bind it to further events.
4269 * @see LuCI.validation
4271 addValidator: function(field, type, optional, vfunc /*, ... */) {
4275 var events = this.varargs(arguments, 3);
4276 if (events.length == 0)
4277 events.push('blur', 'keyup');
4280 var cbiValidator = validation.create(field, type, optional, vfunc),
4281 validatorFn = cbiValidator.validate.bind(cbiValidator);
4283 for (var i = 0; i < events.length; i++)
4284 field.addEventListener(events[i], validatorFn);
4294 * Create a pre-bound event handler function.
4296 * Generate and bind a function suitable for use in event handlers. The
4297 * generated function automatically disables the event source element
4298 * and adds an active indication to it by adding appropriate CSS classes.
4300 * It will also await any promises returned by the wrapped function and
4301 * re-enable the source element after the promises ran to completion.
4304 * The `this` context to use for the wrapped function.
4306 * @param {function|string} fn
4307 * Specifies the function to wrap. In case of a function value, the
4308 * function is used as-is. If a string is specified instead, it is looked
4309 * up in `ctx` to obtain the function to wrap. In both cases the bound
4310 * function will be invoked with `ctx` as `this` context
4312 * @param {...*} extra_args
4313 * Any further parameter as passed as-is to the bound event handler
4314 * function in the same order as passed to `createHandlerFn()`.
4316 * @returns {function|null}
4317 * Returns the pre-bound handler function which is suitable to be passed
4318 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4319 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4320 * valid function value.
4322 createHandlerFn: function(ctx, fn /*, ... */) {
4323 if (typeof(fn) == 'string')
4326 if (typeof(fn) != 'function')
4329 var arg_offset = arguments.length - 2;
4331 return Function.prototype.bind.apply(function() {
4332 var t = arguments[arg_offset].currentTarget;
4334 t.classList.add('spinning');
4340 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4341 t.classList.remove('spinning');
4344 }, this.varargs(arguments, 2, ctx));
4348 * Load specified view class path and set it up.
4350 * Transforms the given view path into a class name, requires it
4351 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4352 * resulting class instance is a descendant of
4353 * [LuCI.view]{@link LuCI.view}.
4355 * By instantiating the view class, its corresponding contents are
4356 * rendered and included into the view area. Any runtime errors are
4357 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4359 * @param {string} path
4360 * The view path to render.
4362 * @returns {Promise<LuCI.view>}
4363 * Returns a promise resolving to the loaded view instance.
4365 instantiateView: function(path) {
4366 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4368 return L.require(className).then(function(view) {
4369 if (!(view instanceof View))
4370 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4373 }).catch(function(err) {
4374 dom.content(document.querySelector('#view'), null);
4381 AbstractElement: UIElement,
4384 Textfield: UITextfield,
4385 Textarea: UITextarea,
4386 Checkbox: UICheckbox,
4388 Dropdown: UIDropdown,
4389 DynamicList: UIDynamicList,
4390 Combobox: UICombobox,
4391 ComboButton: UIComboButton,
4392 Hiddenfield: UIHiddenfield,
4393 FileUpload: UIFileUpload