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 });
313 var inputEl = E('input', {
314 'id': this.options.id ? 'widget.' + this.options.id : null,
315 'name': this.options.name,
317 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
318 'readonly': this.options.readonly ? '' : null,
319 'disabled': this.options.disabled ? '' : null,
320 'maxlength': this.options.maxlength,
321 'placeholder': this.options.placeholder,
325 if (this.options.password) {
326 frameEl.appendChild(E('div', { 'class': 'control-group' }, [
329 'class': 'cbi-button cbi-button-neutral',
330 'title': _('Reveal/hide password'),
331 'aria-label': _('Reveal/hide password'),
332 'click': function(ev) {
333 var e = this.previousElementSibling;
334 e.type = (e.type === 'password') ? 'text' : 'password';
340 window.requestAnimationFrame(function() { inputEl.type = 'password' });
343 frameEl.appendChild(inputEl);
346 return this.bind(frameEl);
350 bind: function(frameEl) {
351 var inputEl = frameEl.querySelector('input');
355 this.setUpdateEvents(inputEl, 'keyup', 'blur');
356 this.setChangeEvents(inputEl, 'change');
358 dom.bindClassInstance(frameEl, this);
364 getValue: function() {
365 var inputEl = this.node.querySelector('input');
366 return inputEl.value;
370 setValue: function(value) {
371 var inputEl = this.node.querySelector('input');
372 inputEl.value = value;
377 * Instantiate a textarea widget.
379 * @constructor Textarea
381 * @augments LuCI.ui.AbstractElement
385 * The `Textarea` class implements a multiline text area input field.
387 * UI widget instances are usually not supposed to be created by view code
388 * directly, instead they're implicitely created by `LuCI.form` when
389 * instantiating CBI forms.
391 * This class is automatically instantiated as part of `LuCI.ui`. To use it
392 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
393 * external JavaScript, use `L.require("ui").then(...)` and access the
394 * `Textarea` property of the class instance value.
396 * @param {string} [value=null]
397 * The initial input value.
399 * @param {LuCI.ui.Textarea.InitOptions} [options]
400 * Object describing the widget specific options to initialize the input.
402 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
404 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
405 * the following properties are recognized:
407 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
408 * @memberof LuCI.ui.Textarea
410 * @property {boolean} [readonly=false]
411 * Specifies whether the input widget should be rendered readonly.
413 * @property {string} [placeholder]
414 * Specifies the HTML `placeholder` attribute which is displayed when the
415 * corresponding `<textarea>` element is empty.
417 * @property {boolean} [monospace=false]
418 * Specifies whether a monospace font should be forced for the textarea
421 * @property {number} [cols]
422 * Specifies the HTML `cols` attribute to set on the corresponding
423 * `<textarea>` element.
425 * @property {number} [rows]
426 * Specifies the HTML `rows` attribute to set on the corresponding
427 * `<textarea>` element.
429 * @property {boolean} [wrap=false]
430 * Specifies whether the HTML `wrap` attribute should be set.
432 __init__: function(value, options) {
434 this.options = Object.assign({
444 var style = !this.options.cols ? 'width:100%' : null,
445 frameEl = E('div', { 'id': this.options.id, 'style': style }),
446 value = (this.value != null) ? String(this.value) : '';
448 frameEl.appendChild(E('textarea', {
449 'id': this.options.id ? 'widget.' + this.options.id : null,
450 'name': this.options.name,
451 'class': 'cbi-input-textarea',
452 'readonly': this.options.readonly ? '' : null,
453 'disabled': this.options.disabled ? '' : null,
454 'placeholder': this.options.placeholder,
456 'cols': this.options.cols,
457 'rows': this.options.rows,
458 'wrap': this.options.wrap ? '' : null
461 if (this.options.monospace)
462 frameEl.firstElementChild.style.fontFamily = 'monospace';
464 return this.bind(frameEl);
468 bind: function(frameEl) {
469 var inputEl = frameEl.firstElementChild;
473 this.setUpdateEvents(inputEl, 'keyup', 'blur');
474 this.setChangeEvents(inputEl, 'change');
476 dom.bindClassInstance(frameEl, this);
482 getValue: function() {
483 return this.node.firstElementChild.value;
487 setValue: function(value) {
488 this.node.firstElementChild.value = value;
493 * Instantiate a checkbox widget.
495 * @constructor Checkbox
497 * @augments LuCI.ui.AbstractElement
501 * The `Checkbox` class implements a simple checkbox input field.
503 * UI widget instances are usually not supposed to be created by view code
504 * directly, instead they're implicitely created by `LuCI.form` when
505 * instantiating CBI forms.
507 * This class is automatically instantiated as part of `LuCI.ui`. To use it
508 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
509 * external JavaScript, use `L.require("ui").then(...)` and access the
510 * `Checkbox` property of the class instance value.
512 * @param {string} [value=null]
513 * The initial input value.
515 * @param {LuCI.ui.Checkbox.InitOptions} [options]
516 * Object describing the widget specific options to initialize the input.
518 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
520 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
521 * the following properties are recognized:
523 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
524 * @memberof LuCI.ui.Checkbox
526 * @property {string} [value_enabled=1]
527 * Specifies the value corresponding to a checked checkbox.
529 * @property {string} [value_disabled=0]
530 * Specifies the value corresponding to an unchecked checkbox.
532 * @property {string} [hiddenname]
533 * Specifies the HTML `name` attribute of the hidden input backing the
534 * checkbox. This is a legacy property existing for compatibility reasons,
535 * it is required for HTML based form submissions.
537 __init__: function(value, options) {
539 this.options = Object.assign({
547 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
548 var frameEl = E('div', {
549 'id': this.options.id,
550 'class': 'cbi-checkbox'
553 if (this.options.hiddenname)
554 frameEl.appendChild(E('input', {
556 'name': this.options.hiddenname,
560 frameEl.appendChild(E('input', {
562 'name': this.options.name,
564 'value': this.options.value_enabled,
565 'checked': (this.value == this.options.value_enabled) ? '' : null,
566 'disabled': this.options.disabled ? '' : null,
567 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
570 frameEl.appendChild(E('label', { 'for': id }));
572 return this.bind(frameEl);
576 bind: function(frameEl) {
579 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
580 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
582 dom.bindClassInstance(frameEl, this);
588 * Test whether the checkbox is currently checked.
591 * @memberof LuCI.ui.Checkbox
593 * Returns `true` when the checkbox is currently checked, otherwise `false`.
595 isChecked: function() {
596 return this.node.lastElementChild.previousElementSibling.checked;
600 getValue: function() {
601 return this.isChecked()
602 ? this.options.value_enabled
603 : this.options.value_disabled;
607 setValue: function(value) {
608 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
613 * Instantiate a select dropdown or checkbox/radiobutton group.
615 * @constructor Select
617 * @augments LuCI.ui.AbstractElement
621 * The `Select` class implements either a traditional HTML `<select>` element
622 * or a group of checkboxes or radio buttons, depending on whether multiple
623 * values are enabled or not.
625 * UI widget instances are usually not supposed to be created by view code
626 * directly, instead they're implicitely created by `LuCI.form` when
627 * instantiating CBI forms.
629 * This class is automatically instantiated as part of `LuCI.ui`. To use it
630 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
631 * external JavaScript, use `L.require("ui").then(...)` and access the
632 * `Select` property of the class instance value.
634 * @param {string|string[]} [value=null]
635 * The initial input value(s).
637 * @param {Object<string, string>} choices
638 * Object containing the selectable choices of the widget. The object keys
639 * serve as values for the different choices while the values are used as
642 * @param {LuCI.ui.Select.InitOptions} [options]
643 * Object describing the widget specific options to initialize the inputs.
645 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
647 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
648 * the following properties are recognized:
650 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
651 * @memberof LuCI.ui.Select
653 * @property {boolean} [multiple=false]
654 * Specifies whether multiple choice values may be selected.
656 * @property {string} [widget=select]
657 * Specifies the kind of widget to render. May be either `select` or
658 * `individual`. When set to `select` an HTML `<select>` element will be
659 * used, otherwise a group of checkbox or radio button elements is created,
660 * depending on the value of the `multiple` option.
662 * @property {string} [orientation=horizontal]
663 * Specifies whether checkbox / radio button groups should be rendered
664 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
667 * @property {boolean|string[]} [sort=false]
668 * Specifies if and how to sort choice values. If set to `true`, the choice
669 * values will be sorted alphabetically. If set to an array of strings, the
670 * choice sort order is derived from the array.
672 * @property {number} [size]
673 * Specifies the HTML `size` attribute to set on the `<select>` element.
674 * Only applicable to the `select` widget type.
676 * @property {string} [placeholder=-- Please choose --]
677 * Specifies a placeholder text which is displayed when no choice is
678 * selected yet. Only applicable to the `select` widget type.
680 __init__: function(value, choices, options) {
681 if (!L.isObject(choices))
684 if (!Array.isArray(value))
685 value = (value != null && value != '') ? [ value ] : [];
687 if (!options.multiple && value.length > 1)
691 this.choices = choices;
692 this.options = Object.assign({
695 orientation: 'horizontal'
698 if (this.choices.hasOwnProperty(''))
699 this.options.optional = true;
704 var frameEl = E('div', { 'id': this.options.id }),
705 keys = Object.keys(this.choices);
707 if (this.options.sort === true)
709 else if (Array.isArray(this.options.sort))
710 keys = this.options.sort;
712 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
713 frameEl.appendChild(E('select', {
714 'id': this.options.id ? 'widget.' + this.options.id : null,
715 'name': this.options.name,
716 'size': this.options.size,
717 'class': 'cbi-input-select',
718 'multiple': this.options.multiple ? '' : null,
719 'disabled': this.options.disabled ? '' : null
722 if (this.options.optional)
723 frameEl.lastChild.appendChild(E('option', {
725 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
726 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
728 for (var i = 0; i < keys.length; i++) {
729 if (keys[i] == null || keys[i] == '')
732 frameEl.lastChild.appendChild(E('option', {
734 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
735 }, [ this.choices[keys[i]] || keys[i] ]));
739 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' \xa0 ') : E('br');
741 for (var i = 0; i < keys.length; i++) {
742 frameEl.appendChild(E('span', {
743 'class': 'cbi-%s'.format(this.options.multiple ? 'checkbox' : 'radio')
746 'id': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null,
747 'name': this.options.id || this.options.name,
748 'type': this.options.multiple ? 'checkbox' : 'radio',
749 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
751 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
752 'disabled': this.options.disabled ? '' : null
754 E('label', { 'for': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null }),
756 'click': function(ev) {
757 ev.currentTarget.previousElementSibling.previousElementSibling.click();
759 }, [ this.choices[keys[i]] || keys[i] ])
762 frameEl.appendChild(brEl.cloneNode());
766 return this.bind(frameEl);
770 bind: function(frameEl) {
773 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
774 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
775 this.setChangeEvents(frameEl.firstChild, 'change');
778 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
779 for (var i = 0; i < radioEls.length; i++) {
780 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
781 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
785 dom.bindClassInstance(frameEl, this);
791 getValue: function() {
792 if (this.options.widget != 'radio' && this.options.widget != 'checkbox')
793 return this.node.firstChild.value;
795 var radioEls = this.node.querySelectorAll('input[type="radio"]');
796 for (var i = 0; i < radioEls.length; i++)
797 if (radioEls[i].checked)
798 return radioEls[i].value;
804 setValue: function(value) {
805 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
809 for (var i = 0; i < this.node.firstChild.options.length; i++)
810 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
815 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
816 for (var i = 0; i < radioEls.length; i++)
817 radioEls[i].checked = (radioEls[i].value == value);
822 * Instantiate a rich dropdown choice widget.
824 * @constructor Dropdown
826 * @augments LuCI.ui.AbstractElement
830 * The `Dropdown` class implements a rich, stylable dropdown menu which
831 * supports non-text choice labels.
833 * UI widget instances are usually not supposed to be created by view code
834 * directly, instead they're implicitely created by `LuCI.form` when
835 * instantiating CBI forms.
837 * This class is automatically instantiated as part of `LuCI.ui`. To use it
838 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
839 * external JavaScript, use `L.require("ui").then(...)` and access the
840 * `Dropdown` property of the class instance value.
842 * @param {string|string[]} [value=null]
843 * The initial input value(s).
845 * @param {Object<string, *>} choices
846 * Object containing the selectable choices of the widget. The object keys
847 * serve as values for the different choices while the values are used as
850 * @param {LuCI.ui.Dropdown.InitOptions} [options]
851 * Object describing the widget specific options to initialize the dropdown.
853 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
855 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
856 * the following properties are recognized:
858 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
859 * @memberof LuCI.ui.Dropdown
861 * @property {boolean} [optional=true]
862 * Specifies whether the dropdown selection is optional. In contrast to
863 * other widgets, the `optional` constraint of dropdowns works differently;
864 * instead of marking the widget invalid on empty values when set to `false`,
865 * the user is not allowed to deselect all choices.
867 * For single value dropdowns that means that no empty "please select"
868 * choice is offered and for multi value dropdowns, the last selected choice
869 * may not be deselected without selecting another choice first.
871 * @property {boolean} [multiple]
872 * Specifies whether multiple choice values may be selected. It defaults
873 * to `true` when an array is passed as input value to the constructor.
875 * @property {boolean|string[]} [sort=false]
876 * Specifies if and how to sort choice values. If set to `true`, the choice
877 * values will be sorted alphabetically. If set to an array of strings, the
878 * choice sort order is derived from the array.
880 * @property {string} [select_placeholder=-- Please choose --]
881 * Specifies a placeholder text which is displayed when no choice is
884 * @property {string} [custom_placeholder=-- custom --]
885 * Specifies a placeholder text which is displayed in the text input
886 * field allowing to enter custom choice values. Only applicable if the
887 * `create` option is set to `true`.
889 * @property {boolean} [create=false]
890 * Specifies whether custom choices may be entered into the dropdown
893 * @property {string} [create_query=.create-item-input]
894 * Specifies a CSS selector expression used to find the input element
895 * which is used to enter custom choice values. This should not normally
896 * be used except by widgets derived from the Dropdown class.
898 * @property {string} [create_template=script[type="item-template"]]
899 * Specifies a CSS selector expression used to find an HTML element
900 * serving as template for newly added custom choice values.
902 * Any `{{value}}` placeholder string within the template elements text
903 * content will be replaced by the user supplied choice value, the
904 * resulting string is parsed as HTML and appended to the end of the
905 * choice list. The template markup may specify one HTML element with a
906 * `data-label-placeholder` attribute which is replaced by a matching
907 * label value from the `choices` object or with the user supplied value
908 * itself in case `choices` contains no matching choice label.
910 * If the template element is not found or if no `create_template` selector
911 * expression is specified, the default markup for newly created elements is
912 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
914 * @property {string} [create_markup]
915 * This property allows specifying the markup for custom choices directly
916 * instead of referring to a template element through CSS selectors.
918 * Apart from that it works exactly like `create_template`.
920 * @property {number} [display_items=3]
921 * Specifies the maximum amount of choice labels that should be shown in
922 * collapsed dropdown state before further selected choices are cut off.
924 * Only applicable when `multiple` is `true`.
926 * @property {number} [dropdown_items=-1]
927 * Specifies the maximum amount of choices that should be shown when the
928 * dropdown is open. If the amount of available choices exceeds this number,
929 * the dropdown area must be scrolled to reach further items.
931 * If set to `-1`, the dropdown menu will attempt to show all choice values
932 * and only resort to scrolling if the amount of choices exceeds the available
933 * screen space above and below the dropdown widget.
935 * @property {string} [placeholder]
936 * This property serves as a shortcut to set both `select_placeholder` and
937 * `custom_placeholder`. Either of these properties will fallback to
938 * `placeholder` if not specified.
940 * @property {boolean} [readonly=false]
941 * Specifies whether the custom choice input field should be rendered
942 * readonly. Only applicable when `create` is `true`.
944 * @property {number} [maxlength]
945 * Specifies the HTML `maxlength` attribute to set on the custom choice
946 * `<input>` element. Note that this a legacy property that exists for
947 * compatibility reasons. It is usually better to `maxlength(N)` validation
948 * expression. Only applicable when `create` is `true`.
950 __init__: function(value, choices, options) {
951 if (typeof(choices) != 'object')
954 if (!Array.isArray(value))
955 this.values = (value != null && value != '') ? [ value ] : [];
959 this.choices = choices;
960 this.options = Object.assign({
962 multiple: Array.isArray(value),
964 select_placeholder: _('-- Please choose --'),
965 custom_placeholder: _('-- custom --'),
969 create_query: '.create-item-input',
970 create_template: 'script[type="item-template"]'
977 'id': this.options.id,
978 'class': 'cbi-dropdown',
979 'multiple': this.options.multiple ? '' : null,
980 'optional': this.options.optional ? '' : null,
981 'disabled': this.options.disabled ? '' : null
984 var keys = Object.keys(this.choices);
986 if (this.options.sort === true)
988 else if (Array.isArray(this.options.sort))
989 keys = this.options.sort;
991 if (this.options.create)
992 for (var i = 0; i < this.values.length; i++)
993 if (!this.choices.hasOwnProperty(this.values[i]))
994 keys.push(this.values[i]);
996 for (var i = 0; i < keys.length; i++) {
997 var label = this.choices[keys[i]];
1000 label = label.cloneNode(true);
1002 sb.lastElementChild.appendChild(E('li', {
1003 'data-value': keys[i],
1004 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
1005 }, [ label || keys[i] ]));
1008 if (this.options.create) {
1009 var createEl = E('input', {
1011 'class': 'create-item-input',
1012 'readonly': this.options.readonly ? '' : null,
1013 'maxlength': this.options.maxlength,
1014 'placeholder': this.options.custom_placeholder || this.options.placeholder
1017 if (this.options.datatype || this.options.validate)
1018 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1019 true, this.options.validate, 'blur', 'keyup');
1021 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1024 if (this.options.create_markup)
1025 sb.appendChild(E('script', { type: 'item-template' },
1026 this.options.create_markup));
1028 return this.bind(sb);
1032 bind: function(sb) {
1033 var o = this.options;
1035 o.multiple = sb.hasAttribute('multiple');
1036 o.optional = sb.hasAttribute('optional');
1037 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1038 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1039 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1040 o.create_query = sb.getAttribute('item-create') || o.create_query;
1041 o.create_template = sb.getAttribute('item-template') || o.create_template;
1043 var ul = sb.querySelector('ul'),
1044 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1045 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, 'â–¾')),
1046 canary = sb.appendChild(E('div')),
1047 create = sb.querySelector(this.options.create_query),
1048 ndisplay = this.options.display_items,
1051 if (this.options.multiple) {
1052 var items = ul.querySelectorAll('li');
1054 for (var i = 0; i < items.length; i++) {
1055 this.transformItem(sb, items[i]);
1057 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1058 items[i].setAttribute('display', n++);
1062 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1063 var placeholder = E('li', { placeholder: '' },
1064 this.options.select_placeholder || this.options.placeholder);
1067 ? ul.insertBefore(placeholder, ul.firstChild)
1068 : ul.appendChild(placeholder);
1071 var items = ul.querySelectorAll('li'),
1072 sel = sb.querySelectorAll('[selected]');
1074 sel.forEach(function(s) {
1075 s.removeAttribute('selected');
1078 var s = sel[0] || items[0];
1080 s.setAttribute('selected', '');
1081 s.setAttribute('display', n++);
1087 this.saveValues(sb, ul);
1089 ul.setAttribute('tabindex', -1);
1090 sb.setAttribute('tabindex', 0);
1093 sb.setAttribute('more', '')
1095 sb.removeAttribute('more');
1097 if (ndisplay == this.options.display_items)
1098 sb.setAttribute('empty', '')
1100 sb.removeAttribute('empty');
1102 dom.content(more, (ndisplay == this.options.display_items)
1103 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1106 sb.addEventListener('click', this.handleClick.bind(this));
1107 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1108 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1109 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1111 if ('ontouchstart' in window) {
1112 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1113 window.addEventListener('touchstart', this.closeAllDropdowns);
1116 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1117 sb.addEventListener('focus', this.handleFocus.bind(this));
1119 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1121 window.addEventListener('mouseover', this.setFocus);
1122 window.addEventListener('click', this.closeAllDropdowns);
1126 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1127 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1128 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1130 var li = findParent(create, 'li');
1132 li.setAttribute('unselectable', '');
1133 li.addEventListener('click', this.handleCreateClick.bind(this));
1138 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1139 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1141 dom.bindClassInstance(sb, this);
1147 openDropdown: function(sb) {
1148 var st = window.getComputedStyle(sb, null),
1149 ul = sb.querySelector('ul'),
1150 li = ul.querySelectorAll('li'),
1151 fl = findParent(sb, '.cbi-value-field'),
1152 sel = ul.querySelector('[selected]'),
1153 rect = sb.getBoundingClientRect(),
1154 items = Math.min(this.options.dropdown_items, li.length);
1156 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1157 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1160 sb.setAttribute('open', '');
1162 var pv = ul.cloneNode(true);
1163 pv.classList.add('preview');
1166 fl.classList.add('cbi-dropdown-open');
1168 if ('ontouchstart' in window) {
1169 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1170 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1173 ul.style.top = sb.offsetHeight + 'px';
1174 ul.style.left = -rect.left + 'px';
1175 ul.style.right = (rect.right - vpWidth) + 'px';
1176 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1177 ul.style.WebkitOverflowScrolling = 'touch';
1179 var getScrollParent = function(element) {
1180 var parent = element,
1181 style = getComputedStyle(element),
1182 excludeStaticParent = (style.position === 'absolute');
1184 if (style.position === 'fixed')
1185 return document.body;
1187 while ((parent = parent.parentElement) != null) {
1188 style = getComputedStyle(parent);
1190 if (excludeStaticParent && style.position === 'static')
1193 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1197 return document.body;
1200 var scrollParent = getScrollParent(sb),
1201 scrollFrom = scrollParent.scrollTop,
1202 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1204 var scrollStep = function(timestamp) {
1207 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1210 var duration = Math.max(timestamp - start, 1);
1211 if (duration < 100) {
1212 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1213 window.requestAnimationFrame(scrollStep);
1216 scrollParent.scrollTop = scrollTo;
1220 window.requestAnimationFrame(scrollStep);
1223 ul.style.maxHeight = '1px';
1224 ul.style.top = ul.style.bottom = '';
1226 window.requestAnimationFrame(function() {
1227 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1229 spaceAbove = rect.top,
1230 spaceBelow = window.innerHeight - rect.height - rect.top;
1232 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1233 fullHeight += li[i].getBoundingClientRect().height;
1235 if (fullHeight <= spaceBelow) {
1236 ul.style.top = rect.height + 'px';
1237 ul.style.maxHeight = spaceBelow + 'px';
1239 else if (fullHeight <= spaceAbove) {
1240 ul.style.bottom = rect.height + 'px';
1241 ul.style.maxHeight = spaceAbove + 'px';
1243 else if (spaceBelow >= spaceAbove) {
1244 ul.style.top = rect.height + 'px';
1245 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1248 ul.style.bottom = rect.height + 'px';
1249 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1252 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1256 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1257 for (var i = 0; i < cboxes.length; i++) {
1258 cboxes[i].checked = true;
1259 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1262 ul.classList.add('dropdown');
1264 sb.insertBefore(pv, ul.nextElementSibling);
1266 li.forEach(function(l) {
1267 l.setAttribute('tabindex', 0);
1270 sb.lastElementChild.setAttribute('tabindex', 0);
1272 this.setFocus(sb, sel || li[0], true);
1276 closeDropdown: function(sb, no_focus) {
1277 if (!sb.hasAttribute('open'))
1280 var pv = sb.querySelector('ul.preview'),
1281 ul = sb.querySelector('ul.dropdown'),
1282 li = ul.querySelectorAll('li'),
1283 fl = findParent(sb, '.cbi-value-field');
1285 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1286 sb.lastElementChild.removeAttribute('tabindex');
1289 sb.removeAttribute('open');
1290 sb.style.width = sb.style.height = '';
1292 ul.classList.remove('dropdown');
1293 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1296 fl.classList.remove('cbi-dropdown-open');
1299 this.setFocus(sb, sb);
1301 this.saveValues(sb, ul);
1305 toggleItem: function(sb, li, force_state) {
1306 if (li.hasAttribute('unselectable'))
1309 if (this.options.multiple) {
1310 var cbox = li.querySelector('input[type="checkbox"]'),
1311 items = li.parentNode.querySelectorAll('li'),
1312 label = sb.querySelector('ul.preview'),
1313 sel = li.parentNode.querySelectorAll('[selected]').length,
1314 more = sb.querySelector('.more'),
1315 ndisplay = this.options.display_items,
1318 if (li.hasAttribute('selected')) {
1319 if (force_state !== true) {
1320 if (sel > 1 || this.options.optional) {
1321 li.removeAttribute('selected');
1322 cbox.checked = cbox.disabled = false;
1326 cbox.disabled = true;
1331 if (force_state !== false) {
1332 li.setAttribute('selected', '');
1333 cbox.checked = true;
1334 cbox.disabled = false;
1339 while (label && label.firstElementChild)
1340 label.removeChild(label.firstElementChild);
1342 for (var i = 0; i < items.length; i++) {
1343 items[i].removeAttribute('display');
1344 if (items[i].hasAttribute('selected')) {
1345 if (ndisplay-- > 0) {
1346 items[i].setAttribute('display', n++);
1348 label.appendChild(items[i].cloneNode(true));
1350 var c = items[i].querySelector('input[type="checkbox"]');
1352 c.disabled = (sel == 1 && !this.options.optional);
1357 sb.setAttribute('more', '');
1359 sb.removeAttribute('more');
1361 if (ndisplay === this.options.display_items)
1362 sb.setAttribute('empty', '');
1364 sb.removeAttribute('empty');
1366 dom.content(more, (ndisplay === this.options.display_items)
1367 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1370 var sel = li.parentNode.querySelector('[selected]');
1372 sel.removeAttribute('display');
1373 sel.removeAttribute('selected');
1376 li.setAttribute('display', 0);
1377 li.setAttribute('selected', '');
1379 this.closeDropdown(sb, true);
1382 this.saveValues(sb, li.parentNode);
1386 transformItem: function(sb, li) {
1387 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1390 while (li.firstChild)
1391 label.appendChild(li.firstChild);
1393 li.appendChild(cbox);
1394 li.appendChild(label);
1398 saveValues: function(sb, ul) {
1399 var sel = ul.querySelectorAll('li[selected]'),
1400 div = sb.lastElementChild,
1401 name = this.options.name,
1405 while (div.lastElementChild)
1406 div.removeChild(div.lastElementChild);
1408 sel.forEach(function (s) {
1409 if (s.hasAttribute('placeholder'))
1414 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1418 div.appendChild(E('input', {
1426 strval += strval.length ? ' ' + v.value : v.value;
1434 if (this.options.multiple)
1435 detail.values = values;
1437 detail.value = values.length ? values[0] : null;
1441 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1448 setValues: function(sb, values) {
1449 var ul = sb.querySelector('ul');
1451 if (this.options.create) {
1452 for (var value in values) {
1453 this.createItems(sb, value);
1455 if (!this.options.multiple)
1460 if (this.options.multiple) {
1461 var lis = ul.querySelectorAll('li[data-value]');
1462 for (var i = 0; i < lis.length; i++) {
1463 var value = lis[i].getAttribute('data-value');
1464 if (values === null || !(value in values))
1465 this.toggleItem(sb, lis[i], false);
1467 this.toggleItem(sb, lis[i], true);
1471 var ph = ul.querySelector('li[placeholder]');
1473 this.toggleItem(sb, ph);
1475 var lis = ul.querySelectorAll('li[data-value]');
1476 for (var i = 0; i < lis.length; i++) {
1477 var value = lis[i].getAttribute('data-value');
1478 if (values !== null && (value in values))
1479 this.toggleItem(sb, lis[i]);
1485 setFocus: function(sb, elem, scroll) {
1486 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1489 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1492 document.querySelectorAll('.focus').forEach(function(e) {
1493 if (!matchesElem(e, 'input')) {
1494 e.classList.remove('focus');
1501 elem.classList.add('focus');
1504 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1509 createChoiceElement: function(sb, value, label) {
1510 var tpl = sb.querySelector(this.options.create_template),
1514 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1516 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1518 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1519 placeholder = new_item.querySelector('[data-label-placeholder]');
1522 var content = E('span', {}, label || this.choices[value] || [ value ]);
1524 while (content.firstChild)
1525 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1527 placeholder.parentNode.removeChild(placeholder);
1530 if (this.options.multiple)
1531 this.transformItem(sb, new_item);
1537 createItems: function(sb, value) {
1539 val = (value || '').trim(),
1540 ul = sb.querySelector('ul');
1542 if (!sbox.options.multiple)
1543 val = val.length ? [ val ] : [];
1545 val = val.length ? val.split(/\s+/) : [];
1547 val.forEach(function(item) {
1548 var new_item = null;
1550 ul.childNodes.forEach(function(li) {
1551 if (li.getAttribute && li.getAttribute('data-value') === item)
1556 new_item = sbox.createChoiceElement(sb, item);
1558 if (!sbox.options.multiple) {
1559 var old = ul.querySelector('li[created]');
1561 ul.removeChild(old);
1563 new_item.setAttribute('created', '');
1566 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1569 sbox.toggleItem(sb, new_item, true);
1570 sbox.setFocus(sb, new_item, true);
1575 * Remove all existing choices from the dropdown menu.
1577 * This function removes all preexisting dropdown choices from the widget,
1578 * keeping only choices currently being selected unless `reset_values` is
1579 * given, in which case all choices and deselected and removed.
1582 * @memberof LuCI.ui.Dropdown
1583 * @param {boolean} [reset_value=false]
1584 * If set to `true`, deselect and remove selected choices as well instead
1587 clearChoices: function(reset_value) {
1588 var ul = this.node.querySelector('ul'),
1589 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1590 len = lis.length - (this.options.create ? 1 : 0),
1591 val = reset_value ? null : this.getValue();
1593 for (var i = 0; i < len; i++) {
1594 var lival = lis[i].getAttribute('data-value');
1596 (!this.options.multiple && val != lival) ||
1597 (this.options.multiple && val.indexOf(lival) == -1))
1598 ul.removeChild(lis[i]);
1602 this.setValues(this.node, {});
1606 * Add new choices to the dropdown menu.
1608 * This function adds further choices to an existing dropdown menu,
1609 * ignoring choice values which are already present.
1612 * @memberof LuCI.ui.Dropdown
1613 * @param {string[]} values
1614 * The choice values to add to the dropdown widget.
1616 * @param {Object<string, *>} labels
1617 * The choice label values to use when adding dropdown choices. If no
1618 * label is found for a particular choice value, the value itself is used
1619 * as label text. Choice labels may be any valid value accepted by
1620 * {@link LuCI.dom#content}.
1622 addChoices: function(values, labels) {
1624 ul = sb.querySelector('ul'),
1625 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1627 if (!Array.isArray(values))
1628 values = L.toArray(values);
1630 if (!L.isObject(labels))
1633 for (var i = 0; i < values.length; i++) {
1636 for (var j = 0; j < lis.length; j++) {
1637 if (lis[j].getAttribute('data-value') === values[i]) {
1647 this.createChoiceElement(sb, values[i], labels[values[i]]),
1648 ul.lastElementChild);
1653 * Close all open dropdown widgets in the current document.
1655 closeAllDropdowns: function() {
1656 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1657 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1662 handleClick: function(ev) {
1663 var sb = ev.currentTarget;
1665 if (!sb.hasAttribute('open')) {
1666 if (!matchesElem(ev.target, 'input'))
1667 this.openDropdown(sb);
1670 var li = findParent(ev.target, 'li');
1671 if (li && li.parentNode.classList.contains('dropdown'))
1672 this.toggleItem(sb, li);
1673 else if (li && li.parentNode.classList.contains('preview'))
1674 this.closeDropdown(sb);
1675 else if (matchesElem(ev.target, 'span.open, span.more'))
1676 this.closeDropdown(sb);
1679 ev.preventDefault();
1680 ev.stopPropagation();
1684 handleKeydown: function(ev) {
1685 var sb = ev.currentTarget;
1687 if (matchesElem(ev.target, 'input'))
1690 if (!sb.hasAttribute('open')) {
1691 switch (ev.keyCode) {
1696 this.openDropdown(sb);
1697 ev.preventDefault();
1701 var active = findParent(document.activeElement, 'li');
1703 switch (ev.keyCode) {
1705 this.closeDropdown(sb);
1710 if (!active.hasAttribute('selected'))
1711 this.toggleItem(sb, active);
1712 this.closeDropdown(sb);
1713 ev.preventDefault();
1719 this.toggleItem(sb, active);
1720 ev.preventDefault();
1725 if (active && active.previousElementSibling) {
1726 this.setFocus(sb, active.previousElementSibling);
1727 ev.preventDefault();
1732 if (active && active.nextElementSibling) {
1733 this.setFocus(sb, active.nextElementSibling);
1734 ev.preventDefault();
1742 handleDropdownClose: function(ev) {
1743 var sb = ev.currentTarget;
1745 this.closeDropdown(sb, true);
1749 handleDropdownSelect: function(ev) {
1750 var sb = ev.currentTarget,
1751 li = findParent(ev.target, 'li');
1756 this.toggleItem(sb, li);
1757 this.closeDropdown(sb, true);
1761 handleMouseover: function(ev) {
1762 var sb = ev.currentTarget;
1764 if (!sb.hasAttribute('open'))
1767 var li = findParent(ev.target, 'li');
1769 if (li && li.parentNode.classList.contains('dropdown'))
1770 this.setFocus(sb, li);
1774 handleFocus: function(ev) {
1775 var sb = ev.currentTarget;
1777 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1778 if (s !== sb || sb.hasAttribute('open'))
1779 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1784 handleCanaryFocus: function(ev) {
1785 this.closeDropdown(ev.currentTarget.parentNode);
1789 handleCreateKeydown: function(ev) {
1790 var input = ev.currentTarget,
1791 sb = findParent(input, '.cbi-dropdown');
1793 switch (ev.keyCode) {
1795 ev.preventDefault();
1797 if (input.classList.contains('cbi-input-invalid'))
1800 this.createItems(sb, input.value);
1808 handleCreateFocus: function(ev) {
1809 var input = ev.currentTarget,
1810 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1811 sb = findParent(input, '.cbi-dropdown');
1814 cbox.checked = true;
1816 sb.setAttribute('locked-in', '');
1820 handleCreateBlur: function(ev) {
1821 var input = ev.currentTarget,
1822 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1823 sb = findParent(input, '.cbi-dropdown');
1826 cbox.checked = false;
1828 sb.removeAttribute('locked-in');
1832 handleCreateClick: function(ev) {
1833 ev.currentTarget.querySelector(this.options.create_query).focus();
1837 setValue: function(values) {
1838 if (this.options.multiple) {
1839 if (!Array.isArray(values))
1840 values = (values != null && values != '') ? [ values ] : [];
1844 for (var i = 0; i < values.length; i++)
1845 v[values[i]] = true;
1847 this.setValues(this.node, v);
1852 if (values != null) {
1853 if (Array.isArray(values))
1854 v[values[0]] = true;
1859 this.setValues(this.node, v);
1864 getValue: function() {
1865 var div = this.node.lastElementChild,
1866 h = div.querySelectorAll('input[type="hidden"]'),
1869 for (var i = 0; i < h.length; i++)
1872 return this.options.multiple ? v : v[0];
1877 * Instantiate a rich dropdown choice widget allowing custom values.
1879 * @constructor Combobox
1881 * @augments LuCI.ui.Dropdown
1885 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1886 * to enter custom values. Historically, comboboxes used to be a dedicated
1887 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1888 * with a set of enforced default properties for easier instantiation.
1890 * UI widget instances are usually not supposed to be created by view code
1891 * directly, instead they're implicitely created by `LuCI.form` when
1892 * instantiating CBI forms.
1894 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1895 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1896 * external JavaScript, use `L.require("ui").then(...)` and access the
1897 * `Combobox` property of the class instance value.
1899 * @param {string|string[]} [value=null]
1900 * The initial input value(s).
1902 * @param {Object<string, *>} choices
1903 * Object containing the selectable choices of the widget. The object keys
1904 * serve as values for the different choices while the values are used as
1907 * @param {LuCI.ui.Combobox.InitOptions} [options]
1908 * Object describing the widget specific options to initialize the dropdown.
1910 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1912 * Comboboxes support the same properties as
1913 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1914 * specific values for the following properties:
1916 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1917 * @memberof LuCI.ui.Combobox
1919 * @property {boolean} multiple=false
1920 * Since Comboboxes never allow selecting multiple values, this property
1921 * is forcibly set to `false`.
1923 * @property {boolean} create=true
1924 * Since Comboboxes always allow custom choice values, this property is
1925 * forcibly set to `true`.
1927 * @property {boolean} optional=true
1928 * Since Comboboxes are always optional, this property is forcibly set to
1931 __init__: function(value, choices, options) {
1932 this.super('__init__', [ value, choices, Object.assign({
1933 select_placeholder: _('-- Please choose --'),
1934 custom_placeholder: _('-- custom --'),
1946 * Instantiate a combo button widget offering multiple action choices.
1948 * @constructor ComboButton
1950 * @augments LuCI.ui.Dropdown
1954 * The `ComboButton` class implements a button element which can be expanded
1955 * into a dropdown to chose from a set of different action choices.
1957 * UI widget instances are usually not supposed to be created by view code
1958 * directly, instead they're implicitely created by `LuCI.form` when
1959 * instantiating CBI forms.
1961 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1962 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1963 * external JavaScript, use `L.require("ui").then(...)` and access the
1964 * `ComboButton` property of the class instance value.
1966 * @param {string|string[]} [value=null]
1967 * The initial input value(s).
1969 * @param {Object<string, *>} choices
1970 * Object containing the selectable choices of the widget. The object keys
1971 * serve as values for the different choices while the values are used as
1974 * @param {LuCI.ui.ComboButton.InitOptions} [options]
1975 * Object describing the widget specific options to initialize the button.
1977 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1979 * ComboButtons support the same properties as
1980 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1981 * specific values for some properties and add aditional button specific
1984 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1985 * @memberof LuCI.ui.ComboButton
1987 * @property {boolean} multiple=false
1988 * Since ComboButtons never allow selecting multiple actions, this property
1989 * is forcibly set to `false`.
1991 * @property {boolean} create=false
1992 * Since ComboButtons never allow creating custom choices, this property
1993 * is forcibly set to `false`.
1995 * @property {boolean} optional=false
1996 * Since ComboButtons must always select one action, this property is
1997 * forcibly set to `false`.
1999 * @property {Object<string, string>} [classes]
2000 * Specifies a mapping of choice values to CSS class names. If an action
2001 * choice is selected by the user and if a corresponding entry exists in
2002 * the `classes` object, the class names corresponding to the selected
2003 * value are set on the button element.
2005 * This is useful to apply different button styles, such as colors, to the
2006 * combined button depending on the selected action.
2008 * @property {function} [click]
2009 * Specifies a handler function to invoke when the user clicks the button.
2010 * This function will be called with the button DOM node as `this` context
2011 * and receive the DOM click event as first as well as the selected action
2012 * choice value as second argument.
2014 __init__: function(value, choices, options) {
2015 this.super('__init__', [ value, choices, Object.assign({
2025 render: function(/* ... */) {
2026 var node = UIDropdown.prototype.render.apply(this, arguments),
2027 val = this.getValue();
2029 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2030 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2036 handleClick: function(ev) {
2037 var sb = ev.currentTarget,
2040 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2041 return UIDropdown.prototype.handleClick.apply(this, arguments);
2043 if (this.options.click)
2044 return this.options.click.call(sb, ev, this.getValue());
2048 toggleItem: function(sb /*, ... */) {
2049 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2050 val = this.getValue();
2052 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2053 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2055 sb.setAttribute('class', 'cbi-dropdown');
2062 * Instantiate a dynamic list widget.
2064 * @constructor DynamicList
2066 * @augments LuCI.ui.AbstractElement
2070 * The `DynamicList` class implements a widget which allows the user to specify
2071 * an arbitrary amount of input values, either from free formed text input or
2072 * from a set of predefined choices.
2074 * UI widget instances are usually not supposed to be created by view code
2075 * directly, instead they're implicitely created by `LuCI.form` when
2076 * instantiating CBI forms.
2078 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2079 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2080 * external JavaScript, use `L.require("ui").then(...)` and access the
2081 * `DynamicList` property of the class instance value.
2083 * @param {string|string[]} [value=null]
2084 * The initial input value(s).
2086 * @param {Object<string, *>} [choices]
2087 * Object containing the selectable choices of the widget. The object keys
2088 * serve as values for the different choices while the values are used as
2089 * choice labels. If omitted, no default choices are presented to the user,
2090 * instead a plain text input field is rendered allowing the user to add
2091 * arbitrary values to the dynamic list.
2093 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2094 * Object describing the widget specific options to initialize the dynamic list.
2096 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2098 * In case choices are passed to the dynamic list contructor, the widget
2099 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2100 * but enforces specific values for some dropdown properties.
2102 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2103 * @memberof LuCI.ui.DynamicList
2105 * @property {boolean} multiple=false
2106 * Since dynamic lists never allow selecting multiple choices when adding
2107 * another list item, this property is forcibly set to `false`.
2109 * @property {boolean} optional=true
2110 * Since dynamic lists use an embedded dropdown to present a list of
2111 * predefined choice values, the dropdown must be made optional to allow
2112 * it to remain unselected.
2114 __init__: function(values, choices, options) {
2115 if (!Array.isArray(values))
2116 values = (values != null && values != '') ? [ values ] : [];
2118 if (typeof(choices) != 'object')
2121 this.values = values;
2122 this.choices = choices;
2123 this.options = Object.assign({}, options, {
2130 render: function() {
2132 'id': this.options.id,
2133 'class': 'cbi-dynlist',
2134 'disabled': this.options.disabled ? '' : null
2135 }, E('div', { 'class': 'add-item' }));
2138 if (this.options.placeholder != null)
2139 this.options.select_placeholder = this.options.placeholder;
2141 var cbox = new UICombobox(null, this.choices, this.options);
2143 dl.lastElementChild.appendChild(cbox.render());
2146 var inputEl = E('input', {
2147 'id': this.options.id ? 'widget.' + this.options.id : null,
2149 'class': 'cbi-input-text',
2150 'placeholder': this.options.placeholder,
2151 'disabled': this.options.disabled ? '' : null
2154 dl.lastElementChild.appendChild(inputEl);
2155 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2157 if (this.options.datatype || this.options.validate)
2158 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2159 true, this.options.validate, 'blur', 'keyup');
2162 for (var i = 0; i < this.values.length; i++) {
2163 var label = this.choices ? this.choices[this.values[i]] : null;
2165 if (dom.elem(label))
2166 label = label.cloneNode(true);
2168 this.addItem(dl, this.values[i], label);
2171 return this.bind(dl);
2175 bind: function(dl) {
2176 dl.addEventListener('click', L.bind(this.handleClick, this));
2177 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2178 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2182 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2183 this.setChangeEvents(dl, 'cbi-dynlist-change');
2185 dom.bindClassInstance(dl, this);
2191 addItem: function(dl, value, text, flash) {
2193 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2194 E('span', {}, [ text || value ]),
2197 'name': this.options.name,
2198 'value': value })]);
2200 dl.querySelectorAll('.item').forEach(function(item) {
2204 var hidden = item.querySelector('input[type="hidden"]');
2206 if (hidden && hidden.parentNode !== item)
2209 if (hidden && hidden.value === value)
2214 var ai = dl.querySelector('.add-item');
2215 ai.parentNode.insertBefore(new_item, ai);
2218 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2230 removeItem: function(dl, item) {
2231 var value = item.querySelector('input[type="hidden"]').value;
2232 var sb = dl.querySelector('.cbi-dropdown');
2234 sb.querySelectorAll('ul > li').forEach(function(li) {
2235 if (li.getAttribute('data-value') === value) {
2236 if (li.hasAttribute('dynlistcustom'))
2237 li.parentNode.removeChild(li);
2239 li.removeAttribute('unselectable');
2243 item.parentNode.removeChild(item);
2245 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2257 handleClick: function(ev) {
2258 var dl = ev.currentTarget,
2259 item = findParent(ev.target, '.item');
2261 if (this.options.disabled)
2265 this.removeItem(dl, item);
2267 else if (matchesElem(ev.target, '.cbi-button-add')) {
2268 var input = ev.target.previousElementSibling;
2269 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2270 this.addItem(dl, input.value, null, true);
2277 handleDropdownChange: function(ev) {
2278 var dl = ev.currentTarget,
2279 sbIn = ev.detail.instance,
2280 sbEl = ev.detail.element,
2281 sbVal = ev.detail.value;
2286 sbIn.setValues(sbEl, null);
2287 sbVal.element.setAttribute('unselectable', '');
2289 if (sbVal.element.hasAttribute('created')) {
2290 sbVal.element.removeAttribute('created');
2291 sbVal.element.setAttribute('dynlistcustom', '');
2294 var label = sbVal.text;
2296 if (sbVal.element) {
2299 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2300 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2303 this.addItem(dl, sbVal.value, label, true);
2307 handleKeydown: function(ev) {
2308 var dl = ev.currentTarget,
2309 item = findParent(ev.target, '.item');
2312 switch (ev.keyCode) {
2313 case 8: /* backspace */
2314 if (item.previousElementSibling)
2315 item.previousElementSibling.focus();
2317 this.removeItem(dl, item);
2320 case 46: /* delete */
2321 if (item.nextElementSibling) {
2322 if (item.nextElementSibling.classList.contains('item'))
2323 item.nextElementSibling.focus();
2325 item.nextElementSibling.firstElementChild.focus();
2328 this.removeItem(dl, item);
2332 else if (matchesElem(ev.target, '.cbi-input-text')) {
2333 switch (ev.keyCode) {
2334 case 13: /* enter */
2335 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2336 this.addItem(dl, ev.target.value, null, true);
2337 ev.target.value = '';
2342 ev.preventDefault();
2349 getValue: function() {
2350 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2351 input = this.node.querySelector('.add-item > input[type="text"]'),
2354 for (var i = 0; i < items.length; i++)
2355 v.push(items[i].value);
2357 if (input && input.value != null && input.value.match(/\S/) &&
2358 input.classList.contains('cbi-input-invalid') == false &&
2359 v.filter(function(s) { return s == input.value }).length == 0)
2360 v.push(input.value);
2366 setValue: function(values) {
2367 if (!Array.isArray(values))
2368 values = (values != null && values != '') ? [ values ] : [];
2370 var items = this.node.querySelectorAll('.item');
2372 for (var i = 0; i < items.length; i++)
2373 if (items[i].parentNode === this.node)
2374 this.removeItem(this.node, items[i]);
2376 for (var i = 0; i < values.length; i++)
2377 this.addItem(this.node, values[i],
2378 this.choices ? this.choices[values[i]] : null);
2382 * Add new suggested choices to the dynamic list.
2384 * This function adds further choices to an existing dynamic list,
2385 * ignoring choice values which are already present.
2388 * @memberof LuCI.ui.DynamicList
2389 * @param {string[]} values
2390 * The choice values to add to the dynamic lists suggestion dropdown.
2392 * @param {Object<string, *>} labels
2393 * The choice label values to use when adding suggested choices. If no
2394 * label is found for a particular choice value, the value itself is used
2395 * as label text. Choice labels may be any valid value accepted by
2396 * {@link LuCI.dom#content}.
2398 addChoices: function(values, labels) {
2399 var dl = this.node.lastElementChild.firstElementChild;
2400 dom.callClassMethod(dl, 'addChoices', values, labels);
2404 * Remove all existing choices from the dynamic list.
2406 * This function removes all preexisting suggested choices from the widget.
2409 * @memberof LuCI.ui.DynamicList
2411 clearChoices: function() {
2412 var dl = this.node.lastElementChild.firstElementChild;
2413 dom.callClassMethod(dl, 'clearChoices');
2418 * Instantiate a hidden input field widget.
2420 * @constructor Hiddenfield
2422 * @augments LuCI.ui.AbstractElement
2426 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2427 * which allows to store form data without exposing it to the user.
2429 * UI widget instances are usually not supposed to be created by view code
2430 * directly, instead they're implicitely created by `LuCI.form` when
2431 * instantiating CBI forms.
2433 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2434 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2435 * external JavaScript, use `L.require("ui").then(...)` and access the
2436 * `Hiddenfield` property of the class instance value.
2438 * @param {string|string[]} [value=null]
2439 * The initial input value.
2441 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2442 * Object describing the widget specific options to initialize the hidden input.
2444 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2445 __init__: function(value, options) {
2447 this.options = Object.assign({
2453 render: function() {
2454 var hiddenEl = E('input', {
2455 'id': this.options.id,
2460 return this.bind(hiddenEl);
2464 bind: function(hiddenEl) {
2465 this.node = hiddenEl;
2467 dom.bindClassInstance(hiddenEl, this);
2473 getValue: function() {
2474 return this.node.value;
2478 setValue: function(value) {
2479 this.node.value = value;
2484 * Instantiate a file upload widget.
2486 * @constructor FileUpload
2488 * @augments LuCI.ui.AbstractElement
2492 * The `FileUpload` class implements a widget which allows the user to upload,
2493 * browse, select and delete files beneath a predefined remote directory.
2495 * UI widget instances are usually not supposed to be created by view code
2496 * directly, instead they're implicitely created by `LuCI.form` when
2497 * instantiating CBI forms.
2499 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2500 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2501 * external JavaScript, use `L.require("ui").then(...)` and access the
2502 * `FileUpload` property of the class instance value.
2504 * @param {string|string[]} [value=null]
2505 * The initial input value.
2507 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2508 * Object describing the widget specific options to initialize the file
2511 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2513 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2514 * the following properties are recognized:
2516 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2517 * @memberof LuCI.ui.FileUpload
2519 * @property {boolean} [show_hidden=false]
2520 * Specifies whether hidden files should be displayed when browsing remote
2521 * files. Note that this is not a security feature, hidden files are always
2522 * present in the remote file listings received, this option merely controls
2523 * whether they're displayed or not.
2525 * @property {boolean} [enable_upload=true]
2526 * Specifies whether the widget allows the user to upload files. If set to
2527 * `false`, only existing files may be selected. Note that this is not a
2528 * security feature. Whether file upload requests are accepted remotely
2529 * depends on the ACL setup for the current session. This option merely
2530 * controls whether the upload controls are rendered or not.
2532 * @property {boolean} [enable_remove=true]
2533 * Specifies whether the widget allows the user to delete remove files.
2534 * If set to `false`, existing files may not be removed. Note that this is
2535 * not a security feature. Whether file delete requests are accepted
2536 * remotely depends on the ACL setup for the current session. This option
2537 * merely controls whether the file remove controls are rendered or not.
2539 * @property {string} [root_directory=/etc/luci-uploads]
2540 * Specifies the remote directory the upload and file browsing actions take
2541 * place in. Browsing to directories outside of the root directory is
2542 * prevented by the widget. Note that this is not a security feature.
2543 * Whether remote directories are browseable or not solely depends on the
2544 * ACL setup for the current session.
2546 __init__: function(value, options) {
2548 this.options = Object.assign({
2550 enable_upload: true,
2551 enable_remove: true,
2552 root_directory: '/etc/luci-uploads'
2557 bind: function(browserEl) {
2558 this.node = browserEl;
2560 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2561 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2563 dom.bindClassInstance(browserEl, this);
2569 render: function() {
2570 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2573 if (L.isObject(stat) && stat.type != 'directory')
2576 if (this.stat != null)
2577 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2578 else if (this.value != null)
2579 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2581 label = [ _('Select file…') ];
2583 return this.bind(E('div', { 'id': this.options.id }, [
2586 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2587 'disabled': this.options.disabled ? '' : null
2590 'class': 'cbi-filebrowser'
2594 'name': this.options.name,
2602 truncatePath: function(path) {
2603 if (path.length > 50)
2604 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2610 iconForType: function(type) {
2614 'src': L.resource('cbi/link.svg'),
2616 'title': _('Symbolic link'),
2622 'src': L.resource('cbi/folder.svg'),
2624 'title': _('Directory'),
2630 'src': L.resource('cbi/file.svg'),
2639 canonicalizePath: function(path) {
2640 return path.replace(/\/{2,}/, '/')
2641 .replace(/\/\.(\/|$)/g, '/')
2642 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2643 .replace(/\/$/, '');
2647 splitPath: function(path) {
2648 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2649 cpath = this.canonicalizePath(path || '/');
2651 if (cpath.length <= croot.length)
2654 if (cpath.charAt(croot.length) != '/')
2657 var parts = cpath.substring(croot.length + 1).split(/\//);
2659 parts.unshift(croot);
2665 handleUpload: function(path, list, ev) {
2666 var form = ev.target.parentNode,
2667 fileinput = form.querySelector('input[type="file"]'),
2668 nameinput = form.querySelector('input[type="text"]'),
2669 filename = (nameinput.value != null ? nameinput.value : '').trim();
2671 ev.preventDefault();
2673 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2676 var existing = list.filter(function(e) { return e.name == filename })[0];
2678 if (existing != null && existing.type == 'directory')
2679 return alert(_('A directory with the same name already exists.'));
2680 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2683 var data = new FormData();
2685 data.append('sessionid', L.env.sessionid);
2686 data.append('filename', path + '/' + filename);
2687 data.append('filedata', fileinput.files[0]);
2689 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2690 progress: L.bind(function(btn, ev) {
2691 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2693 }).then(L.bind(function(path, ev, res) {
2694 var reply = res.json();
2696 if (L.isObject(reply) && reply.failure)
2697 alert(_('Upload request failed: %s').format(reply.message));
2699 return this.handleSelect(path, null, ev);
2700 }, this, path, ev));
2704 handleDelete: function(path, fileStat, ev) {
2705 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2706 name = path.replace(/^.+\//, ''),
2709 ev.preventDefault();
2711 if (fileStat.type == 'directory')
2712 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2714 msg = _('Do you really want to delete "%s" ?').format(name);
2717 var button = this.node.firstElementChild,
2718 hidden = this.node.lastElementChild;
2720 if (path == hidden.value) {
2721 dom.content(button, _('Select file…'));
2725 return fs.remove(path).then(L.bind(function(parent, ev) {
2726 return this.handleSelect(parent, null, ev);
2727 }, this, parent, ev)).catch(function(err) {
2728 alert(_('Delete request failed: %s').format(err.message));
2734 renderUpload: function(path, list) {
2735 if (!this.options.enable_upload)
2741 'class': 'btn cbi-button-positive',
2742 'click': function(ev) {
2743 var uploadForm = ev.target.nextElementSibling,
2744 fileInput = uploadForm.querySelector('input[type="file"]');
2746 ev.target.style.display = 'none';
2747 uploadForm.style.display = '';
2750 }, _('Upload file…')),
2751 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2754 'style': 'display:none',
2755 'change': function(ev) {
2756 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2757 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2759 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2760 uploadbtn.disabled = false;
2765 'click': function(ev) {
2766 ev.preventDefault();
2767 ev.target.previousElementSibling.click();
2769 }, [ _('Browse…') ]),
2770 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2772 'class': 'btn cbi-button-save',
2773 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2775 }, [ _('Upload file') ])
2781 renderListing: function(container, path, list) {
2782 var breadcrumb = E('p'),
2785 list.sort(function(a, b) {
2786 var isDirA = (a.type == 'directory'),
2787 isDirB = (b.type == 'directory');
2789 if (isDirA != isDirB)
2790 return isDirA < isDirB;
2792 return a.name > b.name;
2795 for (var i = 0; i < list.length; i++) {
2796 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2799 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2800 selected = (entrypath == this.node.lastElementChild.value),
2801 mtime = new Date(list[i].mtime * 1000);
2803 rows.appendChild(E('li', [
2804 E('div', { 'class': 'name' }, [
2805 this.iconForType(list[i].type),
2809 'style': selected ? 'font-weight:bold' : null,
2810 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2811 entrypath, list[i].type != 'directory' ? list[i] : null)
2812 }, '%h'.format(list[i].name))
2814 E('div', { 'class': 'mtime hide-xs' }, [
2815 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2816 mtime.getFullYear(),
2817 mtime.getMonth() + 1,
2824 selected ? E('button', {
2826 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2827 }, [ _('Deselect') ]) : '',
2828 this.options.enable_remove ? E('button', {
2829 'class': 'btn cbi-button-negative',
2830 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2831 }, [ _('Delete') ]) : ''
2836 if (!rows.firstElementChild)
2837 rows.appendChild(E('em', _('No entries in this directory')));
2839 var dirs = this.splitPath(path),
2842 for (var i = 0; i < dirs.length; i++) {
2843 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2844 dom.append(breadcrumb, [
2848 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2849 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2853 dom.content(container, [
2856 E('div', { 'class': 'right' }, [
2857 this.renderUpload(path, list),
2861 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2868 handleCancel: function(ev) {
2869 var button = this.node.firstElementChild,
2870 browser = button.nextElementSibling;
2872 browser.classList.remove('open');
2873 button.style.display = '';
2875 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2877 ev.preventDefault();
2881 handleReset: function(ev) {
2882 var button = this.node.firstElementChild,
2883 hidden = this.node.lastElementChild;
2886 dom.content(button, _('Select file…'));
2888 this.handleCancel(ev);
2892 handleSelect: function(path, fileStat, ev) {
2893 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2894 ul = browser.querySelector('ul');
2896 if (fileStat == null) {
2897 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2898 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2901 var button = this.node.firstElementChild,
2902 hidden = this.node.lastElementChild;
2904 path = this.canonicalizePath(path);
2906 dom.content(button, [
2907 this.iconForType(fileStat.type),
2908 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2911 browser.classList.remove('open');
2912 button.style.display = '';
2913 hidden.value = path;
2915 this.stat = Object.assign({ path: path }, fileStat);
2916 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2921 handleFileBrowser: function(ev) {
2922 var button = ev.target,
2923 browser = button.nextElementSibling,
2924 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2926 if (path.indexOf(this.options.root_directory) != 0)
2927 path = this.options.root_directory;
2929 ev.preventDefault();
2931 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2932 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2933 dom.findClassInstance(browserEl).handleCancel(ev);
2936 button.style.display = 'none';
2937 browser.classList.add('open');
2939 return this.renderListing(browser, path, list);
2940 }, this, button, browser, path));
2944 getValue: function() {
2945 return this.node.lastElementChild.value;
2949 setValue: function(value) {
2950 this.node.lastElementChild.value = value;
2955 function scrubMenu(node) {
2956 var hasSatisfiedChild = false;
2958 if (L.isObject(node.children)) {
2959 for (var k in node.children) {
2960 var child = scrubMenu(node.children[k]);
2963 hasSatisfiedChild = hasSatisfiedChild || child.satisfied;
2967 if (L.isObject(node.action) &&
2968 node.action.type == 'firstchild' &&
2969 hasSatisfiedChild == false)
2970 node.satisfied = false;
2985 var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
2987 * @typedef {Object} MenuNode
2988 * @memberof LuCI.ui.menu
2990 * @property {string} name - The internal name of the node, as used in the URL
2991 * @property {number} order - The sort index of the menu node
2992 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
2993 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
2994 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
2995 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
2999 * Load and cache current menu tree.
3001 * @returns {Promise<LuCI.ui.menu.MenuNode>}
3002 * Returns a promise resolving to the root element of the menu tree.
3005 if (this.menu == null)
3006 this.menu = session.getLocalData('menu');
3008 if (!L.isObject(this.menu)) {
3009 this.menu = request.get(L.url('admin/menu')).then(L.bind(function(menu) {
3010 this.menu = scrubMenu(menu.json());
3011 session.setLocalData('menu', this.menu);
3017 return Promise.resolve(this.menu);
3021 * Flush the internal menu cache to force loading a new structure on the
3024 flushCache: function() {
3025 session.setLocalData('menu', null);
3029 * @param {LuCI.ui.menu.MenuNode} [node]
3030 * The menu node to retrieve the children for. Defaults to the menu's
3031 * internal root node if omitted.
3033 * @returns {LuCI.ui.menu.MenuNode[]}
3034 * Returns an array of child menu nodes.
3036 getChildren: function(node) {
3042 for (var k in node.children) {
3043 if (!node.children.hasOwnProperty(k))
3046 if (!node.children[k].satisfied)
3049 if (!node.children[k].hasOwnProperty('title'))
3052 children.push(Object.assign(node.children[k], { name: k }));
3055 return children.sort(function(a, b) {
3056 return ((a.order || 1000) - (b.order || 1000));
3067 * Provides high level UI helper functionality.
3068 * To import the class in views, use `'require ui'`, to import it in
3069 * external JavaScript, use `L.require("ui").then(...)`.
3071 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
3072 __init__: function() {
3073 modalDiv = document.body.appendChild(
3074 dom.create('div', { id: 'modal_overlay' },
3075 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
3077 tooltipDiv = document.body.appendChild(
3078 dom.create('div', { class: 'cbi-tooltip' }));
3080 /* setup old aliases */
3081 L.showModal = this.showModal;
3082 L.hideModal = this.hideModal;
3083 L.showTooltip = this.showTooltip;
3084 L.hideTooltip = this.hideTooltip;
3085 L.itemlist = this.itemlist;
3087 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
3088 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
3089 document.addEventListener('focus', this.showTooltip.bind(this), true);
3090 document.addEventListener('blur', this.hideTooltip.bind(this), true);
3092 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
3093 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
3094 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
3098 * Display a modal overlay dialog with the specified contents.
3100 * The modal overlay dialog covers the current view preventing interaction
3101 * with the underlying view contents. Only one modal dialog instance can
3102 * be opened. Invoking showModal() while a modal dialog is already open will
3103 * replace the open dialog with a new one having the specified contents.
3105 * Additional CSS class names may be passed to influence the appearence of
3106 * the dialog. Valid values for the classes depend on the underlying theme.
3108 * @see LuCI.dom.content
3110 * @param {string} [title]
3111 * The title of the dialog. If `null`, no title element will be rendered.
3113 * @param {*} contents
3114 * The contents to add to the modal dialog. This should be a DOM node or
3115 * a document fragment in most cases. The value is passed as-is to the
3116 * `dom.content()` function - refer to its documentation for applicable
3119 * @param {...string} [classes]
3120 * A number of extra CSS class names which are set on the modal dialog
3124 * Returns a DOM Node representing the modal dialog element.
3126 showModal: function(title, children /* , ... */) {
3127 var dlg = modalDiv.firstElementChild;
3129 dlg.setAttribute('class', 'modal');
3131 for (var i = 2; i < arguments.length; i++)
3132 dlg.classList.add(arguments[i]);
3134 dom.content(dlg, dom.create('h4', {}, title));
3135 dom.append(dlg, children);
3137 document.body.classList.add('modal-overlay-active');
3138 modalDiv.scrollTop = 0;
3144 * Close the open modal overlay dialog.
3146 * This function will close an open modal dialog and restore the normal view
3147 * behaviour. It has no effect if no modal dialog is currently open.
3149 * Note that this function is stand-alone, it does not rely on `this` and
3150 * will not invoke other class functions so it suitable to be used as event
3151 * handler as-is without the need to bind it first.
3153 hideModal: function() {
3154 document.body.classList.remove('modal-overlay-active');
3158 showTooltip: function(ev) {
3159 var target = findParent(ev.target, '[data-tooltip]');
3164 if (tooltipTimeout !== null) {
3165 window.clearTimeout(tooltipTimeout);
3166 tooltipTimeout = null;
3169 var rect = target.getBoundingClientRect(),
3170 x = rect.left + window.pageXOffset,
3171 y = rect.top + rect.height + window.pageYOffset;
3173 tooltipDiv.className = 'cbi-tooltip';
3174 tooltipDiv.innerHTML = 'â–² ';
3175 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3177 if (target.hasAttribute('data-tooltip-style'))
3178 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3180 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3181 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3182 tooltipDiv.firstChild.data = 'â–¼ ' + tooltipDiv.firstChild.data.substr(2);
3185 tooltipDiv.style.top = y + 'px';
3186 tooltipDiv.style.left = x + 'px';
3187 tooltipDiv.style.opacity = 1;
3189 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3191 detail: { target: target }
3196 hideTooltip: function(ev) {
3197 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3198 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3201 if (tooltipTimeout !== null) {
3202 window.clearTimeout(tooltipTimeout);
3203 tooltipTimeout = null;
3206 tooltipDiv.style.opacity = 0;
3207 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3209 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3213 * Add a notification banner at the top of the current view.
3215 * A notification banner is an alert message usually displayed at the
3216 * top of the current view, spanning the entire availibe width.
3217 * Notification banners will stay in place until dismissed by the user.
3218 * Multiple banners may be shown at the same time.
3220 * Additional CSS class names may be passed to influence the appearence of
3221 * the banner. Valid values for the classes depend on the underlying theme.
3223 * @see LuCI.dom.content
3225 * @param {string} [title]
3226 * The title of the notification banner. If `null`, no title element
3229 * @param {*} contents
3230 * The contents to add to the notification banner. This should be a DOM
3231 * node or a document fragment in most cases. The value is passed as-is
3232 * to the `dom.content()` function - refer to its documentation for
3233 * applicable values.
3235 * @param {...string} [classes]
3236 * A number of extra CSS class names which are set on the notification
3240 * Returns a DOM Node representing the notification banner element.
3242 addNotification: function(title, children /*, ... */) {
3243 var mc = document.querySelector('#maincontent') || document.body;
3244 var msg = E('div', {
3245 'class': 'alert-message fade-in',
3246 'style': 'display:flex',
3247 'transitionend': function(ev) {
3248 var node = ev.currentTarget;
3249 if (node.parentNode && node.classList.contains('fade-out'))
3250 node.parentNode.removeChild(node);
3253 E('div', { 'style': 'flex:10' }),
3254 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3257 'style': 'margin-left:auto; margin-top:auto',
3258 'click': function(ev) {
3259 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3262 }, [ _('Dismiss') ])
3267 dom.append(msg.firstElementChild, E('h4', {}, title));
3269 dom.append(msg.firstElementChild, children);
3271 for (var i = 2; i < arguments.length; i++)
3272 msg.classList.add(arguments[i]);
3274 mc.insertBefore(msg, mc.firstElementChild);
3280 * Display or update an header area indicator.
3282 * An indicator is a small label displayed in the header area of the screen
3283 * providing few amounts of status information such as item counts or state
3284 * toggle indicators.
3286 * Multiple indicators may be shown at the same time and indicator labels
3287 * may be made clickable to display extended information or to initiate
3290 * Indicators can either use a default `active` or a less accented `inactive`
3291 * style which is useful for indicators representing state toggles.
3293 * @param {string} id
3294 * The ID of the indicator. If an indicator with the given ID already exists,
3295 * it is updated with the given label and style.
3297 * @param {string} label
3298 * The text to display in the indicator label.
3300 * @param {function} [handler]
3301 * A handler function to invoke when the indicator label is clicked/touched
3302 * by the user. If omitted, the indicator is not clickable/touchable.
3304 * Note that this parameter only applies to new indicators, when updating
3305 * existing labels it is ignored.
3307 * @param {string} [style=active]
3308 * The indicator style to use. May be either `active` or `inactive`.
3310 * @returns {boolean}
3311 * Returns `true` when the indicator has been updated or `false` when no
3312 * changes were made.
3314 showIndicator: function(id, label, handler, style) {
3315 if (indicatorDiv == null) {
3316 indicatorDiv = document.body.querySelector('#indicators');
3318 if (indicatorDiv == null)
3322 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3323 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id));
3325 if (indicatorElem == null) {
3326 var beforeElem = null;
3328 for (beforeElem = indicatorDiv.firstElementChild;
3330 beforeElem = beforeElem.nextElementSibling)
3331 if (beforeElem.getAttribute('data-indicator') > id)
3334 indicatorElem = indicatorDiv.insertBefore(E('span', {
3335 'data-indicator': id,
3336 'data-clickable': handlerFn ? true : null,
3338 }, ['']), beforeElem);
3341 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3344 indicatorElem.firstChild.data = label;
3345 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3350 * Remove an header area indicator.
3352 * This function removes the given indicator label from the header indicator
3353 * area. When the given indicator is not found, this function does nothing.
3355 * @param {string} id
3356 * The ID of the indicator to remove.
3358 * @returns {boolean}
3359 * Returns `true` when the indicator has been removed or `false` when the
3360 * requested indicator was not found.
3362 hideIndicator: function(id) {
3363 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3365 if (indicatorElem == null)
3368 indicatorDiv.removeChild(indicatorElem);
3373 * Formats a series of label/value pairs into list-like markup.
3375 * This function transforms a flat array of alternating label and value
3376 * elements into a list-like markup, using the values in `separators` as
3377 * separators and appends the resulting nodes to the given parent DOM node.
3379 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3380 * `<strong>` element and the value corresponding to the label are
3381 * subsequently wrapped into a `<span class="nowrap">` element.
3383 * The resulting `<span>` element tuples are joined by the given separators
3384 * to form the final markup which is appened to the given parent DOM node.
3386 * @param {Node} node
3387 * The parent DOM node to append the markup to. Any previous child elements
3390 * @param {Array<*>} items
3391 * An alternating array of labels and values. The label values will be
3392 * converted to plain strings, the values are used as-is and may be of
3393 * any type accepted by `LuCI.dom.content()`.
3395 * @param {*|Array<*>} [separators=[E('br')]]
3396 * A single value or an array of separator values to separate each
3397 * label/value pair with. The function will cycle through the separators
3398 * when joining the pairs. If omitted, the default separator is a sole HTML
3399 * `<br>` element. Separator values are used as-is and may be of any type
3400 * accepted by `LuCI.dom.content()`.
3403 * Returns the parent DOM node the formatted markup has been added to.
3405 itemlist: function(node, items, separators) {
3408 if (!Array.isArray(separators))
3409 separators = [ separators || E('br') ];
3411 for (var i = 0; i < items.length; i += 2) {
3412 if (items[i+1] !== null && items[i+1] !== undefined) {
3413 var sep = separators[(i/2) % separators.length],
3416 children.push(E('span', { class: 'nowrap' }, [
3417 items[i] ? E('strong', items[i] + ': ') : '',
3421 if ((i+2) < items.length)
3422 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3426 dom.content(node, children);
3437 * The `tabs` class handles tab menu groups used throughout the view area.
3438 * It takes care of setting up tab groups, tracking their state and handling
3441 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3442 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3443 * external JavaScript, use `L.require("ui").then(...)` and access the
3444 * `tabs` property of the class instance value.
3446 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3449 var groups = [], prevGroup = null, currGroup = null;
3451 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3452 var parent = tab.parentNode;
3454 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3457 if (!parent.hasAttribute('data-tab-group'))
3458 parent.setAttribute('data-tab-group', groups.length);
3460 currGroup = +parent.getAttribute('data-tab-group');
3462 if (currGroup !== prevGroup) {
3463 prevGroup = currGroup;
3465 if (!groups[currGroup])
3466 groups[currGroup] = [];
3469 groups[currGroup].push(tab);
3472 for (var i = 0; i < groups.length; i++)
3473 this.initTabGroup(groups[i]);
3475 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3481 * Initializes a new tab group from the given tab pane collection.
3483 * This function cycles through the given tab pane DOM nodes, extracts
3484 * their tab IDs, titles and active states, renders a corresponding
3485 * tab menu and prepends it to the tab panes common parent DOM node.
3487 * The tab menu labels will be set to the value of the `data-tab-title`
3488 * attribute of each corresponding pane. The last pane with the
3489 * `data-tab-active` attribute set to `true` will be selected by default.
3491 * If no pane is marked as active, the first one will be preselected.
3494 * @memberof LuCI.ui.tabs
3495 * @param {Array<Node>|NodeList} panes
3496 * A collection of tab panes to build a tab group menu for. May be a
3497 * plain array of DOM nodes or a NodeList collection, such as the result
3498 * of a `querySelectorAll()` call or the `.childNodes` property of a
3501 initTabGroup: function(panes) {
3502 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3505 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3506 group = panes[0].parentNode,
3507 groupId = +group.getAttribute('data-tab-group'),
3510 if (group.getAttribute('data-initialized') === 'true')
3513 for (var i = 0, pane; pane = panes[i]; i++) {
3514 var name = pane.getAttribute('data-tab'),
3515 title = pane.getAttribute('data-tab-title'),
3516 active = pane.getAttribute('data-tab-active') === 'true';
3518 menu.appendChild(E('li', {
3519 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3520 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3524 'click': this.switchTab.bind(this)
3531 group.parentNode.insertBefore(menu, group);
3532 group.setAttribute('data-initialized', true);
3534 if (selected === null) {
3535 selected = this.getActiveTabId(panes[0]);
3537 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3538 for (var i = 0; i < panes.length; i++) {
3539 if (!this.isEmptyPane(panes[i])) {
3546 menu.childNodes[selected].classList.add('cbi-tab');
3547 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3548 panes[selected].setAttribute('data-tab-active', 'true');
3550 this.setActiveTabId(panes[selected], selected);
3553 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3554 detail: { tab: panes[selected].getAttribute('data-tab') }
3557 this.updateTabs(group);
3561 * Checks whether the given tab pane node is empty.
3564 * @memberof LuCI.ui.tabs
3565 * @param {Node} pane
3566 * The tab pane to check.
3568 * @returns {boolean}
3569 * Returns `true` if the pane is empty, else `false`.
3571 isEmptyPane: function(pane) {
3572 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3576 getPathForPane: function(pane) {
3577 var path = [], node = null;
3579 for (node = pane ? pane.parentNode : null;
3580 node != null && node.hasAttribute != null;
3581 node = node.parentNode)
3583 if (node.hasAttribute('data-tab'))
3584 path.unshift(node.getAttribute('data-tab'));
3585 else if (node.hasAttribute('data-section-id'))
3586 path.unshift(node.getAttribute('data-section-id'));
3589 return path.join('/');
3593 getActiveTabState: function() {
3594 var page = document.body.getAttribute('data-page'),
3595 state = session.getLocalData('tab');
3597 if (L.isObject(state) && state.page === page && L.isObject(state.paths))
3600 session.setLocalData('tab', null);
3602 return { page: page, paths: {} };
3606 getActiveTabId: function(pane) {
3607 var path = this.getPathForPane(pane);
3608 return +this.getActiveTabState().paths[path] || 0;
3612 setActiveTabId: function(pane, tabIndex) {
3613 var path = this.getPathForPane(pane),
3614 state = this.getActiveTabState();
3616 state.paths[path] = tabIndex;
3618 return session.setLocalData('tab', state);
3622 updateTabs: function(ev, root) {
3623 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3624 var menu = pane.parentNode.previousElementSibling,
3625 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3626 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3631 if (this.isEmptyPane(pane)) {
3632 tab.style.display = 'none';
3633 tab.classList.remove('flash');
3635 else if (tab.style.display === 'none') {
3636 tab.style.display = '';
3637 requestAnimationFrame(function() { tab.classList.add('flash') });
3641 tab.setAttribute('data-errors', n_errors);
3642 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3643 tab.setAttribute('data-tooltip-style', 'error');
3646 tab.removeAttribute('data-errors');
3647 tab.removeAttribute('data-tooltip');
3653 switchTab: function(ev) {
3654 var tab = ev.target.parentNode,
3655 name = tab.getAttribute('data-tab'),
3656 menu = tab.parentNode,
3657 group = menu.nextElementSibling,
3658 groupId = +group.getAttribute('data-tab-group'),
3661 ev.preventDefault();
3663 if (!tab.classList.contains('cbi-tab-disabled'))
3666 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3667 tab.classList.remove('cbi-tab');
3668 tab.classList.remove('cbi-tab-disabled');
3670 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3673 group.childNodes.forEach(function(pane) {
3674 if (dom.matches(pane, '[data-tab]')) {
3675 if (pane.getAttribute('data-tab') === name) {
3676 pane.setAttribute('data-tab-active', 'true');
3677 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3678 UI.prototype.tabs.setActiveTabId(pane, index);
3681 pane.setAttribute('data-tab-active', 'false');
3691 * @typedef {Object} FileUploadReply
3694 * @property {string} name - Name of the uploaded file without directory components
3695 * @property {number} size - Size of the uploaded file in bytes
3696 * @property {string} checksum - The MD5 checksum of the received file data
3697 * @property {string} sha256sum - The SHA256 checksum of the received file data
3701 * Display a modal file upload prompt.
3703 * This function opens a modal dialog prompting the user to select and
3704 * upload a file to a predefined remote destination path.
3706 * @param {string} path
3707 * The remote file path to upload the local file to.
3709 * @param {Node} [progessStatusNode]
3710 * An optional DOM text node whose content text is set to the progress
3711 * percentage value during file upload.
3713 * @returns {Promise<LuCI.ui.FileUploadReply>}
3714 * Returns a promise resolving to a file upload status object on success
3715 * or rejecting with an error in case the upload failed or has been
3716 * cancelled by the user.
3718 uploadFile: function(path, progressStatusNode) {
3719 return new Promise(function(resolveFn, rejectFn) {
3720 UI.prototype.showModal(_('Uploading file…'), [
3721 E('p', _('Please select the file to upload.')),
3722 E('div', { 'style': 'display:flex' }, [
3723 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3726 style: 'display:none',
3727 change: function(ev) {
3728 var modal = dom.parent(ev.target, '.modal'),
3729 body = modal.querySelector('p'),
3730 upload = modal.querySelector('.cbi-button-action.important'),
3731 file = ev.currentTarget.files[0];
3738 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3739 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3743 upload.disabled = false;
3749 'click': function(ev) {
3750 ev.target.previousElementSibling.click();
3752 }, [ _('Browse…') ])
3754 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3757 'click': function() {
3758 UI.prototype.hideModal();
3759 rejectFn(new Error('Upload has been cancelled'));
3761 }, [ _('Cancel') ]),
3764 'class': 'btn cbi-button-action important',
3766 'click': function(ev) {
3767 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3769 if (!input.files[0])
3772 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3774 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3776 var data = new FormData();
3778 data.append('sessionid', rpc.getSessionID());
3779 data.append('filename', path);
3780 data.append('filedata', input.files[0]);
3782 var filename = input.files[0].name;
3784 request.post(L.env.cgi_base + '/cgi-upload', data, {
3786 progress: function(pev) {
3787 var percent = (pev.loaded / pev.total) * 100;
3789 if (progressStatusNode)
3790 progressStatusNode.data = '%.2f%%'.format(percent);
3792 progress.setAttribute('title', '%.2f%%'.format(percent));
3793 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3795 }).then(function(res) {
3796 var reply = res.json();
3798 UI.prototype.hideModal();
3800 if (L.isObject(reply) && reply.failure) {
3801 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3802 rejectFn(new Error(reply.failure));
3805 reply.name = filename;
3809 UI.prototype.hideModal();
3821 * Perform a device connectivity test.
3823 * Attempt to fetch a well known ressource from the remote device via HTTP
3824 * in order to test connectivity. This function is mainly useful to wait
3825 * for the router to come back online after a reboot or reconfiguration.
3827 * @param {string} [proto=http]
3828 * The protocol to use for fetching the resource. May be either `http`
3829 * (the default) or `https`.
3831 * @param {string} [host=window.location.host]
3832 * Override the host address to probe. By default the current host as seen
3833 * in the address bar is probed.
3835 * @returns {Promise<Event>}
3836 * Returns a promise resolving to a `load` event in case the device is
3837 * reachable or rejecting with an `error` event in case it is not reachable
3838 * or rejecting with `null` when the connectivity check timed out.
3840 pingDevice: function(proto, ipaddr) {
3841 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3843 return new Promise(function(resolveFn, rejectFn) {
3844 var img = new Image();
3846 img.onload = resolveFn;
3847 img.onerror = rejectFn;
3849 window.setTimeout(rejectFn, 1000);
3856 * Wait for device to come back online and reconnect to it.
3858 * Poll each given hostname or IP address and navigate to it as soon as
3859 * one of the addresses becomes reachable.
3861 * @param {...string} [hosts=[window.location.host]]
3862 * The list of IP addresses and host names to check for reachability.
3863 * If omitted, the current value of `window.location.host` is used by
3866 awaitReconnect: function(/* ... */) {
3867 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3869 window.setTimeout(L.bind(function() {
3870 poll.add(L.bind(function() {
3871 var tasks = [], reachable = false;
3873 for (var i = 0; i < 2; i++)
3874 for (var j = 0; j < ipaddrs.length; j++)
3875 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3876 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3878 return Promise.all(tasks).then(function() {
3881 window.location = reachable;
3894 * The `changes` class encapsulates logic for visualizing, applying,
3895 * confirming and reverting staged UCI changesets.
3897 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3898 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3899 * external JavaScript, use `L.require("ui").then(...)` and access the
3900 * `changes` property of the class instance value.
3902 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3904 if (!L.env.sessionid)
3907 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3911 * Set the change count indicator.
3913 * This function updates or hides the UCI change count indicator,
3914 * depending on the passed change count. When the count is greater
3915 * than 0, the change indicator is displayed or updated, otherwise it
3919 * @memberof LuCI.ui.changes
3920 * @param {number} numChanges
3921 * The number of changes to indicate.
3923 setIndicator: function(n) {
3925 UI.prototype.showIndicator('uci-changes',
3926 '%s: %d'.format(_('Unsaved Changes'), n),
3927 L.bind(this.displayChanges, this));
3930 UI.prototype.hideIndicator('uci-changes');
3935 * Update the change count indicator.
3937 * This function updates the UCI change count indicator from the given
3938 * UCI changeset structure.
3941 * @memberof LuCI.ui.changes
3942 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3943 * The UCI changeset to count.
3945 renderChangeIndicator: function(changes) {
3948 for (var config in changes)
3949 if (changes.hasOwnProperty(config))
3950 n_changes += changes[config].length;
3952 this.changes = changes;
3953 this.setIndicator(n_changes);
3958 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3959 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3960 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3961 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
3962 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3963 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3964 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3965 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3966 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
3967 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3971 * Display the current changelog.
3973 * Open a modal dialog visualizing the currently staged UCI changes
3974 * and offer options to revert or apply the shown changes.
3977 * @memberof LuCI.ui.changes
3979 displayChanges: function() {
3980 var list = E('div', { 'class': 'uci-change-list' }),
3981 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3982 E('div', { 'class': 'cbi-section' }, [
3983 E('strong', _('Legend:')),
3984 E('div', { 'class': 'uci-change-legend' }, [
3985 E('div', { 'class': 'uci-change-legend-label' }, [
3986 E('ins', ' '), ' ', _('Section added') ]),
3987 E('div', { 'class': 'uci-change-legend-label' }, [
3988 E('del', ' '), ' ', _('Section removed') ]),
3989 E('div', { 'class': 'uci-change-legend-label' }, [
3990 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
3991 E('div', { 'class': 'uci-change-legend-label' }, [
3992 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
3994 E('div', { 'class': 'right' }, [
3997 'click': UI.prototype.hideModal
3998 }, [ _('Dismiss') ]), ' ',
4000 'class': 'cbi-button cbi-button-positive important',
4001 'click': L.bind(this.apply, this, true)
4002 }, [ _('Save & Apply') ]), ' ',
4004 'class': 'cbi-button cbi-button-reset',
4005 'click': L.bind(this.revert, this)
4006 }, [ _('Revert') ])])])
4009 for (var config in this.changes) {
4010 if (!this.changes.hasOwnProperty(config))
4013 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
4015 for (var i = 0, added = null; i < this.changes[config].length; i++) {
4016 var chg = this.changes[config][i],
4017 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
4019 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
4025 if (added != null && chg[1] == added[0])
4026 return '@' + added[1] + '[-1]';
4031 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
4038 if (chg[0] == 'add')
4039 added = [ chg[1], chg[2] ];
4043 list.appendChild(E('br'));
4044 dlg.classList.add('uci-dialog');
4048 displayStatus: function(type, content) {
4050 var message = UI.prototype.showModal('', '');
4052 message.classList.add('alert-message');
4053 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4056 dom.content(message, content);
4058 if (!this.was_polling) {
4059 this.was_polling = request.poll.active();
4060 request.poll.stop();
4064 UI.prototype.hideModal();
4066 if (this.was_polling)
4067 request.poll.start();
4072 rollback: function(checked) {
4074 this.displayStatus('warning spinning',
4075 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4076 .format(L.env.apply_rollback)));
4078 var call = function(r, data, duration) {
4079 if (r.status === 204) {
4080 UI.prototype.changes.displayStatus('warning', [
4081 E('h4', _('Configuration changes have been rolled back!')),
4082 E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)),
4083 E('div', { 'class': 'right' }, [
4086 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4087 }, [ _('Dismiss') ]), ' ',
4089 'class': 'btn cbi-button-action important',
4090 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4091 }, [ _('Revert changes') ]), ' ',
4093 'class': 'btn cbi-button-negative important',
4094 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4095 }, [ _('Apply unchecked') ])
4102 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4103 window.setTimeout(function() {
4104 request.request(L.url('admin/uci/confirm'), {
4106 timeout: L.env.apply_timeout * 1000,
4107 query: { sid: L.env.sessionid, token: L.env.token }
4112 call({ status: 0 });
4115 this.displayStatus('warning', [
4116 E('h4', _('Device unreachable!')),
4117 E('p', _('Could not regain access to the device after applying the configuration changes. You might need to reconnect if you modified network related settings such as the IP address or wireless security credentials.'))
4123 confirm: function(checked, deadline, override_token) {
4125 var ts = Date.now();
4127 this.displayStatus('notice');
4130 this.confirm_auth = { token: override_token };
4132 var call = function(r, data, duration) {
4133 if (Date.now() >= deadline) {
4134 window.clearTimeout(tt);
4135 UI.prototype.changes.rollback(checked);
4138 else if (r && (r.status === 200 || r.status === 204)) {
4139 document.dispatchEvent(new CustomEvent('uci-applied'));
4141 UI.prototype.changes.setIndicator(0);
4142 UI.prototype.changes.displayStatus('notice',
4143 E('p', _('Configuration changes applied.')));
4145 window.clearTimeout(tt);
4146 window.setTimeout(function() {
4147 //UI.prototype.changes.displayStatus(false);
4148 window.location = window.location.href.split('#')[0];
4149 }, L.env.apply_display * 1000);
4154 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4155 window.setTimeout(function() {
4156 request.request(L.url('admin/uci/confirm'), {
4158 timeout: L.env.apply_timeout * 1000,
4159 query: UI.prototype.changes.confirm_auth
4160 }).then(call, call);
4164 var tick = function() {
4165 var now = Date.now();
4167 UI.prototype.changes.displayStatus('notice spinning',
4168 E('p', _('Applying configuration changes… %ds')
4169 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4171 if (now >= deadline)
4174 tt = window.setTimeout(tick, 1000 - (now - ts));
4180 /* wait a few seconds for the settings to become effective */
4181 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4185 * Apply the staged configuration changes.
4187 * Start applying staged configuration changes and open a modal dialog
4188 * with a progress indication to prevent interaction with the view
4189 * during the apply process. The modal dialog will be automatically
4190 * closed and the current view reloaded once the apply process is
4194 * @memberof LuCI.ui.changes
4195 * @param {boolean} [checked=false]
4196 * Whether to perform a checked (`true`) configuration apply or an
4197 * unchecked (`false`) one.
4199 * In case of a checked apply, the configuration changes must be
4200 * confirmed within a specific time interval, otherwise the device
4201 * will begin to roll back the changes in order to restore the previous
4204 apply: function(checked) {
4205 this.displayStatus('notice spinning',
4206 E('p', _('Starting configuration apply…')));
4208 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4210 query: { sid: L.env.sessionid, token: L.env.token }
4211 }).then(function(r) {
4212 if (r.status === (checked ? 200 : 204)) {
4213 var tok = null; try { tok = r.json(); } catch(e) {}
4214 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4215 UI.prototype.changes.confirm_auth = tok;
4217 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4219 else if (checked && r.status === 204) {
4220 UI.prototype.changes.displayStatus('notice',
4221 E('p', _('There are no changes to apply')));
4223 window.setTimeout(function() {
4224 UI.prototype.changes.displayStatus(false);
4225 }, L.env.apply_display * 1000);
4228 UI.prototype.changes.displayStatus('warning',
4229 E('p', _('Apply request failed with status <code>%h</code>')
4230 .format(r.responseText || r.statusText || r.status)));
4232 window.setTimeout(function() {
4233 UI.prototype.changes.displayStatus(false);
4234 }, L.env.apply_display * 1000);
4240 * Revert the staged configuration changes.
4242 * Start reverting staged configuration changes and open a modal dialog
4243 * with a progress indication to prevent interaction with the view
4244 * during the revert process. The modal dialog will be automatically
4245 * closed and the current view reloaded once the revert process is
4249 * @memberof LuCI.ui.changes
4251 revert: function() {
4252 this.displayStatus('notice spinning',
4253 E('p', _('Reverting configuration…')));
4255 request.request(L.url('admin/uci/revert'), {
4257 query: { sid: L.env.sessionid, token: L.env.token }
4258 }).then(function(r) {
4259 if (r.status === 200) {
4260 document.dispatchEvent(new CustomEvent('uci-reverted'));
4262 UI.prototype.changes.setIndicator(0);
4263 UI.prototype.changes.displayStatus('notice',
4264 E('p', _('Changes have been reverted.')));
4266 window.setTimeout(function() {
4267 //UI.prototype.changes.displayStatus(false);
4268 window.location = window.location.href.split('#')[0];
4269 }, L.env.apply_display * 1000);
4272 UI.prototype.changes.displayStatus('warning',
4273 E('p', _('Revert request failed with status <code>%h</code>')
4274 .format(r.statusText || r.status)));
4276 window.setTimeout(function() {
4277 UI.prototype.changes.displayStatus(false);
4278 }, L.env.apply_display * 1000);
4285 * Add validation constraints to an input element.
4287 * Compile the given type expression and optional validator function into
4288 * a validation function and bind it to the specified input element events.
4290 * @param {Node} field
4291 * The DOM input element node to bind the validation constraints to.
4293 * @param {string} type
4294 * The datatype specification to describe validation constraints.
4295 * Refer to the `LuCI.validation` class documentation for details.
4297 * @param {boolean} [optional=false]
4298 * Specifies whether empty values are allowed (`true`) or not (`false`).
4299 * If an input element is not marked optional it must not be empty,
4300 * otherwise it will be marked as invalid.
4302 * @param {function} [vfunc]
4303 * Specifies a custom validation function which is invoked after the
4304 * other validation constraints are applied. The validation must return
4305 * `true` to accept the passed value. Any other return type is converted
4306 * to a string and treated as validation error message.
4308 * @param {...string} [events=blur, keyup]
4309 * The list of events to bind. Each received event will trigger a field
4310 * validation. If omitted, the `keyup` and `blur` events are bound by
4313 * @returns {function}
4314 * Returns the compiled validator function which can be used to manually
4315 * trigger field validation or to bind it to further events.
4317 * @see LuCI.validation
4319 addValidator: function(field, type, optional, vfunc /*, ... */) {
4323 var events = this.varargs(arguments, 3);
4324 if (events.length == 0)
4325 events.push('blur', 'keyup');
4328 var cbiValidator = validation.create(field, type, optional, vfunc),
4329 validatorFn = cbiValidator.validate.bind(cbiValidator);
4331 for (var i = 0; i < events.length; i++)
4332 field.addEventListener(events[i], validatorFn);
4342 * Create a pre-bound event handler function.
4344 * Generate and bind a function suitable for use in event handlers. The
4345 * generated function automatically disables the event source element
4346 * and adds an active indication to it by adding appropriate CSS classes.
4348 * It will also await any promises returned by the wrapped function and
4349 * re-enable the source element after the promises ran to completion.
4352 * The `this` context to use for the wrapped function.
4354 * @param {function|string} fn
4355 * Specifies the function to wrap. In case of a function value, the
4356 * function is used as-is. If a string is specified instead, it is looked
4357 * up in `ctx` to obtain the function to wrap. In both cases the bound
4358 * function will be invoked with `ctx` as `this` context
4360 * @param {...*} extra_args
4361 * Any further parameter as passed as-is to the bound event handler
4362 * function in the same order as passed to `createHandlerFn()`.
4364 * @returns {function|null}
4365 * Returns the pre-bound handler function which is suitable to be passed
4366 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4367 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4368 * valid function value.
4370 createHandlerFn: function(ctx, fn /*, ... */) {
4371 if (typeof(fn) == 'string')
4374 if (typeof(fn) != 'function')
4377 var arg_offset = arguments.length - 2;
4379 return Function.prototype.bind.apply(function() {
4380 var t = arguments[arg_offset].currentTarget;
4382 t.classList.add('spinning');
4388 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4389 t.classList.remove('spinning');
4392 }, this.varargs(arguments, 2, ctx));
4396 * Load specified view class path and set it up.
4398 * Transforms the given view path into a class name, requires it
4399 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4400 * resulting class instance is a descendant of
4401 * [LuCI.view]{@link LuCI.view}.
4403 * By instantiating the view class, its corresponding contents are
4404 * rendered and included into the view area. Any runtime errors are
4405 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4407 * @param {string} path
4408 * The view path to render.
4410 * @returns {Promise<LuCI.view>}
4411 * Returns a promise resolving to the loaded view instance.
4413 instantiateView: function(path) {
4414 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4416 return L.require(className).then(function(view) {
4417 if (!(view instanceof View))
4418 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4421 }).catch(function(err) {
4422 dom.content(document.querySelector('#view'), null);
4429 AbstractElement: UIElement,
4432 Textfield: UITextfield,
4433 Textarea: UITextarea,
4434 Checkbox: UICheckbox,
4436 Dropdown: UIDropdown,
4437 DynamicList: UIDynamicList,
4438 Combobox: UICombobox,
4439 ComboButton: UIComboButton,
4440 Hiddenfield: UIHiddenfield,
4441 FileUpload: UIFileUpload