14 tooltipTimeout = null;
17 * @class AbstractElement
22 * The `AbstractElement` class serves as abstract base for the different widgets
23 * implemented by `LuCI.ui`. It provides the common logic for getting and
24 * setting values, for checking the validity state and for wiring up required
27 * UI widget instances are usually not supposed to be created by view code
28 * directly, instead they're implicitely created by `LuCI.form` when
29 * instantiating CBI forms.
31 * This class is automatically instantiated as part of `LuCI.ui`. To use it
32 * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
33 * it in external JavaScript, use `L.require("ui").then(...)` and access the
34 * `AbstractElement` property of the class instance value.
36 var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
38 * @typedef {Object} InitOptions
39 * @memberof LuCI.ui.AbstractElement
41 * @property {string} [id]
42 * Specifies the widget ID to use. It will be used as HTML `id` attribute
43 * on the toplevel widget DOM node.
45 * @property {string} [name]
46 * Specifies the widget name which is set as HTML `name` attribute on the
47 * corresponding `<input>` element.
49 * @property {boolean} [optional=true]
50 * Specifies whether the input field allows empty values.
52 * @property {string} [datatype=string]
53 * An expression describing the input data validation constraints.
54 * It defaults to `string` which will allow any value.
55 * See {@link LuCI.validation} for details on the expression format.
57 * @property {function} [validator]
58 * Specifies a custom validator function which is invoked after the
59 * standard validation constraints are checked. The function should return
60 * `true` to accept the given input value. Any other return value type is
61 * converted to a string and treated as validation error message.
65 * Read the current value of the input widget.
68 * @memberof LuCI.ui.AbstractElement
69 * @returns {string|string[]|null}
70 * The current value of the input element. For simple inputs like text
71 * fields or selects, the return value type will be a - possibly empty -
72 * string. Complex widgets such as `DynamicList` instances may result in
73 * an array of strings or `null` for unset values.
75 getValue: function() {
76 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
77 return this.node.value;
83 * Set the current value of the input widget.
86 * @memberof LuCI.ui.AbstractElement
87 * @param {string|string[]|null} value
88 * The value to set the input element to. For simple inputs like text
89 * fields or selects, the value should be a - possibly empty - string.
90 * Complex widgets such as `DynamicList` instances may accept string array
93 setValue: function(value) {
94 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
95 this.node.value = value;
99 * Check whether the current input value is valid.
102 * @memberof LuCI.ui.AbstractElement
104 * Returns `true` if the current input value is valid or `false` if it does
105 * not meet the validation constraints.
107 isValid: function() {
108 return (this.validState !== false);
112 * Force validation of the current input value.
114 * Usually input validation is automatically triggered by various DOM events
115 * bound to the input widget. In some cases it is required though to manually
116 * trigger validation runs, e.g. when programmatically altering values.
119 * @memberof LuCI.ui.AbstractElement
121 triggerValidation: function() {
122 if (typeof(this.vfunc) != 'function')
125 var wasValid = this.isValid();
129 return (wasValid != this.isValid());
133 * Dispatch a custom (synthetic) event in response to received events.
135 * Sets up event handlers on the given target DOM node for the given event
136 * names that dispatch a custom event of the given type to the widget root
139 * The primary purpose of this function is to set up a series of custom
140 * uniform standard events such as `widget-update`, `validation-success`,
141 * `validation-failure` etc. which are triggered by various different
142 * widget specific native DOM events.
145 * @memberof LuCI.ui.AbstractElement
146 * @param {Node} targetNode
147 * Specifies the DOM node on which the native event listeners should be
150 * @param {string} synevent
151 * The name of the custom event to dispatch to the widget root DOM node.
153 * @param {string[]} events
154 * The native DOM events for which event handlers should be registered.
156 registerEvents: function(targetNode, synevent, events) {
157 var dispatchFn = L.bind(function(ev) {
158 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
161 for (var i = 0; i < events.length; i++)
162 targetNode.addEventListener(events[i], dispatchFn);
166 * Setup listeners for native DOM events that may update the widget value.
168 * Sets up event handlers on the given target DOM node for the given event
169 * names which may cause the input value to update, such as `keyup` or
170 * `onclick` events. In contrast to change events, such update events will
171 * trigger input value validation.
174 * @memberof LuCI.ui.AbstractElement
175 * @param {Node} targetNode
176 * Specifies the DOM node on which the event listeners should be registered.
178 * @param {...string} events
179 * The DOM events for which event handlers should be registered.
181 setUpdateEvents: function(targetNode /*, ... */) {
182 var datatype = this.options.datatype,
183 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
184 validate = this.options.validate,
185 events = this.varargs(arguments, 1);
187 this.registerEvents(targetNode, 'widget-update', events);
189 if (!datatype && !validate)
192 this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
193 targetNode, datatype || 'string',
197 this.node.addEventListener('validation-success', L.bind(function(ev) {
198 this.validState = true;
201 this.node.addEventListener('validation-failure', L.bind(function(ev) {
202 this.validState = false;
207 * Setup listeners for native DOM events that may change the widget value.
209 * Sets up event handlers on the given target DOM node for the given event
210 * names which may cause the input value to change completely, such as
211 * `change` events in a select menu. In contrast to update events, such
212 * change events will not trigger input value validation but they may cause
213 * field dependencies to get re-evaluated and will mark the input widget
217 * @memberof LuCI.ui.AbstractElement
218 * @param {Node} targetNode
219 * Specifies the DOM node on which the event listeners should be registered.
221 * @param {...string} events
222 * The DOM events for which event handlers should be registered.
224 setChangeEvents: function(targetNode /*, ... */) {
225 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
227 for (var i = 1; i < arguments.length; i++)
228 targetNode.addEventListener(arguments[i], tag_changed);
230 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
234 * Render the widget, setup event listeners and return resulting markup.
237 * @memberof LuCI.ui.AbstractElement
240 * Returns a DOM Node or DocumentFragment containing the rendered
243 render: function() {}
247 * Instantiate a text input widget.
249 * @constructor Textfield
251 * @augments LuCI.ui.AbstractElement
255 * The `Textfield` class implements a standard single line text input field.
257 * UI widget instances are usually not supposed to be created by view code
258 * directly, instead they're implicitely created by `LuCI.form` when
259 * instantiating CBI forms.
261 * This class is automatically instantiated as part of `LuCI.ui`. To use it
262 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
263 * external JavaScript, use `L.require("ui").then(...)` and access the
264 * `Textfield` property of the class instance value.
266 * @param {string} [value=null]
267 * The initial input value.
269 * @param {LuCI.ui.Textfield.InitOptions} [options]
270 * Object describing the widget specific options to initialize the input.
272 var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
274 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
275 * the following properties are recognized:
277 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
278 * @memberof LuCI.ui.Textfield
280 * @property {boolean} [password=false]
281 * Specifies whether the input should be rendered as concealed password field.
283 * @property {boolean} [readonly=false]
284 * Specifies whether the input widget should be rendered readonly.
286 * @property {number} [maxlength]
287 * Specifies the HTML `maxlength` attribute to set on the corresponding
288 * `<input>` element. Note that this a legacy property that exists for
289 * compatibility reasons. It is usually better to `maxlength(N)` validation
292 * @property {string} [placeholder]
293 * Specifies the HTML `placeholder` attribute which is displayed when the
294 * corresponding `<input>` element is empty.
296 __init__: function(value, options) {
298 this.options = Object.assign({
306 var frameEl = E('div', { 'id': this.options.id });
308 if (this.options.password) {
309 frameEl.classList.add('nowrap');
310 frameEl.appendChild(E('input', {
312 'style': 'position:absolute; left:-100000px',
315 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
319 frameEl.appendChild(E('input', {
320 'id': this.options.id ? 'widget.' + this.options.id : null,
321 'name': this.options.name,
322 'type': this.options.password ? 'password' : 'text',
323 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
324 'readonly': this.options.readonly ? '' : null,
325 'maxlength': this.options.maxlength,
326 'placeholder': this.options.placeholder,
330 if (this.options.password)
331 frameEl.appendChild(E('button', {
332 'class': 'cbi-button cbi-button-neutral',
333 'title': _('Reveal/hide password'),
334 'aria-label': _('Reveal/hide password'),
335 'click': function(ev) {
336 var e = this.previousElementSibling;
337 e.type = (e.type === 'password') ? 'text' : 'password';
342 return this.bind(frameEl);
346 bind: function(frameEl) {
347 var inputEl = frameEl.childNodes[+!!this.options.password];
351 this.setUpdateEvents(inputEl, 'keyup', 'blur');
352 this.setChangeEvents(inputEl, 'change');
354 dom.bindClassInstance(frameEl, this);
360 getValue: function() {
361 var inputEl = this.node.childNodes[+!!this.options.password];
362 return inputEl.value;
366 setValue: function(value) {
367 var inputEl = this.node.childNodes[+!!this.options.password];
368 inputEl.value = value;
373 * Instantiate a textarea widget.
375 * @constructor Textarea
377 * @augments LuCI.ui.AbstractElement
381 * The `Textarea` class implements a multiline text area input field.
383 * UI widget instances are usually not supposed to be created by view code
384 * directly, instead they're implicitely created by `LuCI.form` when
385 * instantiating CBI forms.
387 * This class is automatically instantiated as part of `LuCI.ui`. To use it
388 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
389 * external JavaScript, use `L.require("ui").then(...)` and access the
390 * `Textarea` property of the class instance value.
392 * @param {string} [value=null]
393 * The initial input value.
395 * @param {LuCI.ui.Textarea.InitOptions} [options]
396 * Object describing the widget specific options to initialize the input.
398 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
400 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
401 * the following properties are recognized:
403 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
404 * @memberof LuCI.ui.Textarea
406 * @property {boolean} [readonly=false]
407 * Specifies whether the input widget should be rendered readonly.
409 * @property {string} [placeholder]
410 * Specifies the HTML `placeholder` attribute which is displayed when the
411 * corresponding `<textarea>` element is empty.
413 * @property {boolean} [monospace=false]
414 * Specifies whether a monospace font should be forced for the textarea
417 * @property {number} [cols]
418 * Specifies the HTML `cols` attribute to set on the corresponding
419 * `<textarea>` element.
421 * @property {number} [rows]
422 * Specifies the HTML `rows` attribute to set on the corresponding
423 * `<textarea>` element.
425 * @property {boolean} [wrap=false]
426 * Specifies whether the HTML `wrap` attribute should be set.
428 __init__: function(value, options) {
430 this.options = Object.assign({
440 var frameEl = E('div', { 'id': this.options.id }),
441 value = (this.value != null) ? String(this.value) : '';
443 frameEl.appendChild(E('textarea', {
444 'id': this.options.id ? 'widget.' + this.options.id : null,
445 'name': this.options.name,
446 'class': 'cbi-input-textarea',
447 'readonly': this.options.readonly ? '' : null,
448 'placeholder': this.options.placeholder,
449 'style': !this.options.cols ? 'width:100%' : null,
450 'cols': this.options.cols,
451 'rows': this.options.rows,
452 'wrap': this.options.wrap ? '' : null
455 if (this.options.monospace)
456 frameEl.firstElementChild.style.fontFamily = 'monospace';
458 return this.bind(frameEl);
462 bind: function(frameEl) {
463 var inputEl = frameEl.firstElementChild;
467 this.setUpdateEvents(inputEl, 'keyup', 'blur');
468 this.setChangeEvents(inputEl, 'change');
470 dom.bindClassInstance(frameEl, this);
476 getValue: function() {
477 return this.node.firstElementChild.value;
481 setValue: function(value) {
482 this.node.firstElementChild.value = value;
487 * Instantiate a checkbox widget.
489 * @constructor Checkbox
491 * @augments LuCI.ui.AbstractElement
495 * The `Checkbox` class implements a simple checkbox input field.
497 * UI widget instances are usually not supposed to be created by view code
498 * directly, instead they're implicitely created by `LuCI.form` when
499 * instantiating CBI forms.
501 * This class is automatically instantiated as part of `LuCI.ui`. To use it
502 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
503 * external JavaScript, use `L.require("ui").then(...)` and access the
504 * `Checkbox` property of the class instance value.
506 * @param {string} [value=null]
507 * The initial input value.
509 * @param {LuCI.ui.Checkbox.InitOptions} [options]
510 * Object describing the widget specific options to initialize the input.
512 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
514 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
515 * the following properties are recognized:
517 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
518 * @memberof LuCI.ui.Checkbox
520 * @property {string} [value_enabled=1]
521 * Specifies the value corresponding to a checked checkbox.
523 * @property {string} [value_disabled=0]
524 * Specifies the value corresponding to an unchecked checkbox.
526 * @property {string} [hiddenname]
527 * Specifies the HTML `name` attribute of the hidden input backing the
528 * checkbox. This is a legacy property existing for compatibility reasons,
529 * it is required for HTML based form submissions.
531 __init__: function(value, options) {
533 this.options = Object.assign({
541 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
542 var frameEl = E('div', {
543 'id': this.options.id,
544 'class': 'cbi-checkbox'
547 if (this.options.hiddenname)
548 frameEl.appendChild(E('input', {
550 'name': this.options.hiddenname,
554 frameEl.appendChild(E('input', {
556 'name': this.options.name,
558 'value': this.options.value_enabled,
559 'checked': (this.value == this.options.value_enabled) ? '' : null,
560 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
563 frameEl.appendChild(E('label', { 'for': id }));
565 return this.bind(frameEl);
569 bind: function(frameEl) {
572 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
573 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
575 dom.bindClassInstance(frameEl, this);
581 * Test whether the checkbox is currently checked.
584 * @memberof LuCI.ui.Checkbox
586 * Returns `true` when the checkbox is currently checked, otherwise `false`.
588 isChecked: function() {
589 return this.node.lastElementChild.previousElementSibling.checked;
593 getValue: function() {
594 return this.isChecked()
595 ? this.options.value_enabled
596 : this.options.value_disabled;
600 setValue: function(value) {
601 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
606 * Instantiate a select dropdown or checkbox/radiobutton group.
608 * @constructor Select
610 * @augments LuCI.ui.AbstractElement
614 * The `Select` class implements either a traditional HTML `<select>` element
615 * or a group of checkboxes or radio buttons, depending on whether multiple
616 * values are enabled or not.
618 * UI widget instances are usually not supposed to be created by view code
619 * directly, instead they're implicitely created by `LuCI.form` when
620 * instantiating CBI forms.
622 * This class is automatically instantiated as part of `LuCI.ui`. To use it
623 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
624 * external JavaScript, use `L.require("ui").then(...)` and access the
625 * `Select` property of the class instance value.
627 * @param {string|string[]} [value=null]
628 * The initial input value(s).
630 * @param {Object<string, string>} choices
631 * Object containing the selectable choices of the widget. The object keys
632 * serve as values for the different choices while the values are used as
635 * @param {LuCI.ui.Select.InitOptions} [options]
636 * Object describing the widget specific options to initialize the inputs.
638 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
640 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
641 * the following properties are recognized:
643 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
644 * @memberof LuCI.ui.Select
646 * @property {boolean} [multiple=false]
647 * Specifies whether multiple choice values may be selected.
649 * @property {string} [widget=select]
650 * Specifies the kind of widget to render. May be either `select` or
651 * `individual`. When set to `select` an HTML `<select>` element will be
652 * used, otherwise a group of checkbox or radio button elements is created,
653 * depending on the value of the `multiple` option.
655 * @property {string} [orientation=horizontal]
656 * Specifies whether checkbox / radio button groups should be rendered
657 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
660 * @property {boolean|string[]} [sort=false]
661 * Specifies if and how to sort choice values. If set to `true`, the choice
662 * values will be sorted alphabetically. If set to an array of strings, the
663 * choice sort order is derived from the array.
665 * @property {number} [size]
666 * Specifies the HTML `size` attribute to set on the `<select>` element.
667 * Only applicable to the `select` widget type.
669 * @property {string} [placeholder=-- Please choose --]
670 * Specifies a placeholder text which is displayed when no choice is
671 * selected yet. Only applicable to the `select` widget type.
673 __init__: function(value, choices, options) {
674 if (!L.isObject(choices))
677 if (!Array.isArray(value))
678 value = (value != null && value != '') ? [ value ] : [];
680 if (!options.multiple && value.length > 1)
684 this.choices = choices;
685 this.options = Object.assign({
688 orientation: 'horizontal'
691 if (this.choices.hasOwnProperty(''))
692 this.options.optional = true;
697 var frameEl = E('div', { 'id': this.options.id }),
698 keys = Object.keys(this.choices);
700 if (this.options.sort === true)
702 else if (Array.isArray(this.options.sort))
703 keys = this.options.sort;
705 if (this.options.widget == 'select') {
706 frameEl.appendChild(E('select', {
707 'id': this.options.id ? 'widget.' + this.options.id : null,
708 'name': this.options.name,
709 'size': this.options.size,
710 'class': 'cbi-input-select',
711 'multiple': this.options.multiple ? '' : null
714 if (this.options.optional)
715 frameEl.lastChild.appendChild(E('option', {
717 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
718 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
720 for (var i = 0; i < keys.length; i++) {
721 if (keys[i] == null || keys[i] == '')
724 frameEl.lastChild.appendChild(E('option', {
726 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
727 }, [ this.choices[keys[i]] || keys[i] ]));
731 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
733 for (var i = 0; i < keys.length; i++) {
734 frameEl.appendChild(E('label', {}, [
736 'id': this.options.id ? 'widget.' + this.options.id : null,
737 'name': this.options.id || this.options.name,
738 'type': this.options.multiple ? 'checkbox' : 'radio',
739 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
741 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null
743 this.choices[keys[i]] || keys[i]
746 if (i + 1 == this.options.size)
747 frameEl.appendChild(brEl);
751 return this.bind(frameEl);
755 bind: function(frameEl) {
758 if (this.options.widget == 'select') {
759 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
760 this.setChangeEvents(frameEl.firstChild, 'change');
763 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
764 for (var i = 0; i < radioEls.length; i++) {
765 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
766 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
770 dom.bindClassInstance(frameEl, this);
776 getValue: function() {
777 if (this.options.widget == 'select')
778 return this.node.firstChild.value;
780 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
781 for (var i = 0; i < radioEls.length; i++)
782 if (radioEls[i].checked)
783 return radioEls[i].value;
789 setValue: function(value) {
790 if (this.options.widget == 'select') {
794 for (var i = 0; i < this.node.firstChild.options.length; i++)
795 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
800 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
801 for (var i = 0; i < radioEls.length; i++)
802 radioEls[i].checked = (radioEls[i].value == value);
807 * Instantiate a rich dropdown choice widget.
809 * @constructor Dropdown
811 * @augments LuCI.ui.AbstractElement
815 * The `Dropdown` class implements a rich, stylable dropdown menu which
816 * supports non-text choice labels.
818 * UI widget instances are usually not supposed to be created by view code
819 * directly, instead they're implicitely created by `LuCI.form` when
820 * instantiating CBI forms.
822 * This class is automatically instantiated as part of `LuCI.ui`. To use it
823 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
824 * external JavaScript, use `L.require("ui").then(...)` and access the
825 * `Dropdown` property of the class instance value.
827 * @param {string|string[]} [value=null]
828 * The initial input value(s).
830 * @param {Object<string, *>} choices
831 * Object containing the selectable choices of the widget. The object keys
832 * serve as values for the different choices while the values are used as
835 * @param {LuCI.ui.Dropdown.InitOptions} [options]
836 * Object describing the widget specific options to initialize the dropdown.
838 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
840 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
841 * the following properties are recognized:
843 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
844 * @memberof LuCI.ui.Dropdown
846 * @property {boolean} [optional=true]
847 * Specifies whether the dropdown selection is optional. In contrast to
848 * other widgets, the `optional` constraint of dropdowns works differently;
849 * instead of marking the widget invalid on empty values when set to `false`,
850 * the user is not allowed to deselect all choices.
852 * For single value dropdowns that means that no empty "please select"
853 * choice is offered and for multi value dropdowns, the last selected choice
854 * may not be deselected without selecting another choice first.
856 * @property {boolean} [multiple]
857 * Specifies whether multiple choice values may be selected. It defaults
858 * to `true` when an array is passed as input value to the constructor.
860 * @property {boolean|string[]} [sort=false]
861 * Specifies if and how to sort choice values. If set to `true`, the choice
862 * values will be sorted alphabetically. If set to an array of strings, the
863 * choice sort order is derived from the array.
865 * @property {string} [select_placeholder=-- Please choose --]
866 * Specifies a placeholder text which is displayed when no choice is
869 * @property {string} [custom_placeholder=-- custom --]
870 * Specifies a placeholder text which is displayed in the text input
871 * field allowing to enter custom choice values. Only applicable if the
872 * `create` option is set to `true`.
874 * @property {boolean} [create=false]
875 * Specifies whether custom choices may be entered into the dropdown
878 * @property {string} [create_query=.create-item-input]
879 * Specifies a CSS selector expression used to find the input element
880 * which is used to enter custom choice values. This should not normally
881 * be used except by widgets derived from the Dropdown class.
883 * @property {string} [create_template=script[type="item-template"]]
884 * Specifies a CSS selector expression used to find an HTML element
885 * serving as template for newly added custom choice values.
887 * Any `{{value}}` placeholder string within the template elements text
888 * content will be replaced by the user supplied choice value, the
889 * resulting string is parsed as HTML and appended to the end of the
890 * choice list. The template markup may specify one HTML element with a
891 * `data-label-placeholder` attribute which is replaced by a matching
892 * label value from the `choices` object or with the user supplied value
893 * itself in case `choices` contains no matching choice label.
895 * If the template element is not found or if no `create_template` selector
896 * expression is specified, the default markup for newly created elements is
897 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
899 * @property {string} [create_markup]
900 * This property allows specifying the markup for custom choices directly
901 * instead of referring to a template element through CSS selectors.
903 * Apart from that it works exactly like `create_template`.
905 * @property {number} [display_items=3]
906 * Specifies the maximum amount of choice labels that should be shown in
907 * collapsed dropdown state before further selected choices are cut off.
909 * Only applicable when `multiple` is `true`.
911 * @property {number} [dropdown_items=-1]
912 * Specifies the maximum amount of choices that should be shown when the
913 * dropdown is open. If the amount of available choices exceeds this number,
914 * the dropdown area must be scrolled to reach further items.
916 * If set to `-1`, the dropdown menu will attempt to show all choice values
917 * and only resort to scrolling if the amount of choices exceeds the available
918 * screen space above and below the dropdown widget.
920 * @property {string} [placeholder]
921 * This property serves as a shortcut to set both `select_placeholder` and
922 * `custom_placeholder`. Either of these properties will fallback to
923 * `placeholder` if not specified.
925 * @property {boolean} [readonly=false]
926 * Specifies whether the custom choice input field should be rendered
927 * readonly. Only applicable when `create` is `true`.
929 * @property {number} [maxlength]
930 * Specifies the HTML `maxlength` attribute to set on the custom choice
931 * `<input>` element. Note that this a legacy property that exists for
932 * compatibility reasons. It is usually better to `maxlength(N)` validation
933 * expression. Only applicable when `create` is `true`.
935 __init__: function(value, choices, options) {
936 if (typeof(choices) != 'object')
939 if (!Array.isArray(value))
940 this.values = (value != null && value != '') ? [ value ] : [];
944 this.choices = choices;
945 this.options = Object.assign({
947 multiple: Array.isArray(value),
949 select_placeholder: _('-- Please choose --'),
950 custom_placeholder: _('-- custom --'),
954 create_query: '.create-item-input',
955 create_template: 'script[type="item-template"]'
962 'id': this.options.id,
963 'class': 'cbi-dropdown',
964 'multiple': this.options.multiple ? '' : null,
965 'optional': this.options.optional ? '' : null,
968 var keys = Object.keys(this.choices);
970 if (this.options.sort === true)
972 else if (Array.isArray(this.options.sort))
973 keys = this.options.sort;
975 if (this.options.create)
976 for (var i = 0; i < this.values.length; i++)
977 if (!this.choices.hasOwnProperty(this.values[i]))
978 keys.push(this.values[i]);
980 for (var i = 0; i < keys.length; i++) {
981 var label = this.choices[keys[i]];
984 label = label.cloneNode(true);
986 sb.lastElementChild.appendChild(E('li', {
987 'data-value': keys[i],
988 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
989 }, [ label || keys[i] ]));
992 if (this.options.create) {
993 var createEl = E('input', {
995 'class': 'create-item-input',
996 'readonly': this.options.readonly ? '' : null,
997 'maxlength': this.options.maxlength,
998 'placeholder': this.options.custom_placeholder || this.options.placeholder
1001 if (this.options.datatype || this.options.validate)
1002 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1003 true, this.options.validate, 'blur', 'keyup');
1005 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1008 if (this.options.create_markup)
1009 sb.appendChild(E('script', { type: 'item-template' },
1010 this.options.create_markup));
1012 return this.bind(sb);
1016 bind: function(sb) {
1017 var o = this.options;
1019 o.multiple = sb.hasAttribute('multiple');
1020 o.optional = sb.hasAttribute('optional');
1021 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1022 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1023 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1024 o.create_query = sb.getAttribute('item-create') || o.create_query;
1025 o.create_template = sb.getAttribute('item-template') || o.create_template;
1027 var ul = sb.querySelector('ul'),
1028 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1029 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
1030 canary = sb.appendChild(E('div')),
1031 create = sb.querySelector(this.options.create_query),
1032 ndisplay = this.options.display_items,
1035 if (this.options.multiple) {
1036 var items = ul.querySelectorAll('li');
1038 for (var i = 0; i < items.length; i++) {
1039 this.transformItem(sb, items[i]);
1041 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1042 items[i].setAttribute('display', n++);
1046 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1047 var placeholder = E('li', { placeholder: '' },
1048 this.options.select_placeholder || this.options.placeholder);
1051 ? ul.insertBefore(placeholder, ul.firstChild)
1052 : ul.appendChild(placeholder);
1055 var items = ul.querySelectorAll('li'),
1056 sel = sb.querySelectorAll('[selected]');
1058 sel.forEach(function(s) {
1059 s.removeAttribute('selected');
1062 var s = sel[0] || items[0];
1064 s.setAttribute('selected', '');
1065 s.setAttribute('display', n++);
1071 this.saveValues(sb, ul);
1073 ul.setAttribute('tabindex', -1);
1074 sb.setAttribute('tabindex', 0);
1077 sb.setAttribute('more', '')
1079 sb.removeAttribute('more');
1081 if (ndisplay == this.options.display_items)
1082 sb.setAttribute('empty', '')
1084 sb.removeAttribute('empty');
1086 dom.content(more, (ndisplay == this.options.display_items)
1087 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1090 sb.addEventListener('click', this.handleClick.bind(this));
1091 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1092 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1093 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1095 if ('ontouchstart' in window) {
1096 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1097 window.addEventListener('touchstart', this.closeAllDropdowns);
1100 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1101 sb.addEventListener('focus', this.handleFocus.bind(this));
1103 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1105 window.addEventListener('mouseover', this.setFocus);
1106 window.addEventListener('click', this.closeAllDropdowns);
1110 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1111 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1112 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1114 var li = findParent(create, 'li');
1116 li.setAttribute('unselectable', '');
1117 li.addEventListener('click', this.handleCreateClick.bind(this));
1122 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1123 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1125 dom.bindClassInstance(sb, this);
1131 openDropdown: function(sb) {
1132 var st = window.getComputedStyle(sb, null),
1133 ul = sb.querySelector('ul'),
1134 li = ul.querySelectorAll('li'),
1135 fl = findParent(sb, '.cbi-value-field'),
1136 sel = ul.querySelector('[selected]'),
1137 rect = sb.getBoundingClientRect(),
1138 items = Math.min(this.options.dropdown_items, li.length);
1140 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1141 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1144 sb.setAttribute('open', '');
1146 var pv = ul.cloneNode(true);
1147 pv.classList.add('preview');
1150 fl.classList.add('cbi-dropdown-open');
1152 if ('ontouchstart' in window) {
1153 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1154 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1157 ul.style.top = sb.offsetHeight + 'px';
1158 ul.style.left = -rect.left + 'px';
1159 ul.style.right = (rect.right - vpWidth) + 'px';
1160 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1161 ul.style.WebkitOverflowScrolling = 'touch';
1163 function getScrollParent(element) {
1164 var parent = element,
1165 style = getComputedStyle(element),
1166 excludeStaticParent = (style.position === 'absolute');
1168 if (style.position === 'fixed')
1169 return document.body;
1171 while ((parent = parent.parentElement) != null) {
1172 style = getComputedStyle(parent);
1174 if (excludeStaticParent && style.position === 'static')
1177 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1181 return document.body;
1184 var scrollParent = getScrollParent(sb),
1185 scrollFrom = scrollParent.scrollTop,
1186 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1188 var scrollStep = function(timestamp) {
1191 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1194 var duration = Math.max(timestamp - start, 1);
1195 if (duration < 100) {
1196 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1197 window.requestAnimationFrame(scrollStep);
1200 scrollParent.scrollTop = scrollTo;
1204 window.requestAnimationFrame(scrollStep);
1207 ul.style.maxHeight = '1px';
1208 ul.style.top = ul.style.bottom = '';
1210 window.requestAnimationFrame(function() {
1211 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1213 spaceAbove = rect.top,
1214 spaceBelow = window.innerHeight - rect.height - rect.top;
1216 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1217 fullHeight += li[i].getBoundingClientRect().height;
1219 if (fullHeight <= spaceBelow) {
1220 ul.style.top = rect.height + 'px';
1221 ul.style.maxHeight = spaceBelow + 'px';
1223 else if (fullHeight <= spaceAbove) {
1224 ul.style.bottom = rect.height + 'px';
1225 ul.style.maxHeight = spaceAbove + 'px';
1227 else if (spaceBelow >= spaceAbove) {
1228 ul.style.top = rect.height + 'px';
1229 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1232 ul.style.bottom = rect.height + 'px';
1233 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1236 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1240 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1241 for (var i = 0; i < cboxes.length; i++) {
1242 cboxes[i].checked = true;
1243 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1246 ul.classList.add('dropdown');
1248 sb.insertBefore(pv, ul.nextElementSibling);
1250 li.forEach(function(l) {
1251 l.setAttribute('tabindex', 0);
1254 sb.lastElementChild.setAttribute('tabindex', 0);
1256 this.setFocus(sb, sel || li[0], true);
1260 closeDropdown: function(sb, no_focus) {
1261 if (!sb.hasAttribute('open'))
1264 var pv = sb.querySelector('ul.preview'),
1265 ul = sb.querySelector('ul.dropdown'),
1266 li = ul.querySelectorAll('li'),
1267 fl = findParent(sb, '.cbi-value-field');
1269 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1270 sb.lastElementChild.removeAttribute('tabindex');
1273 sb.removeAttribute('open');
1274 sb.style.width = sb.style.height = '';
1276 ul.classList.remove('dropdown');
1277 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1280 fl.classList.remove('cbi-dropdown-open');
1283 this.setFocus(sb, sb);
1285 this.saveValues(sb, ul);
1289 toggleItem: function(sb, li, force_state) {
1290 if (li.hasAttribute('unselectable'))
1293 if (this.options.multiple) {
1294 var cbox = li.querySelector('input[type="checkbox"]'),
1295 items = li.parentNode.querySelectorAll('li'),
1296 label = sb.querySelector('ul.preview'),
1297 sel = li.parentNode.querySelectorAll('[selected]').length,
1298 more = sb.querySelector('.more'),
1299 ndisplay = this.options.display_items,
1302 if (li.hasAttribute('selected')) {
1303 if (force_state !== true) {
1304 if (sel > 1 || this.options.optional) {
1305 li.removeAttribute('selected');
1306 cbox.checked = cbox.disabled = false;
1310 cbox.disabled = true;
1315 if (force_state !== false) {
1316 li.setAttribute('selected', '');
1317 cbox.checked = true;
1318 cbox.disabled = false;
1323 while (label && label.firstElementChild)
1324 label.removeChild(label.firstElementChild);
1326 for (var i = 0; i < items.length; i++) {
1327 items[i].removeAttribute('display');
1328 if (items[i].hasAttribute('selected')) {
1329 if (ndisplay-- > 0) {
1330 items[i].setAttribute('display', n++);
1332 label.appendChild(items[i].cloneNode(true));
1334 var c = items[i].querySelector('input[type="checkbox"]');
1336 c.disabled = (sel == 1 && !this.options.optional);
1341 sb.setAttribute('more', '');
1343 sb.removeAttribute('more');
1345 if (ndisplay === this.options.display_items)
1346 sb.setAttribute('empty', '');
1348 sb.removeAttribute('empty');
1350 dom.content(more, (ndisplay === this.options.display_items)
1351 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1354 var sel = li.parentNode.querySelector('[selected]');
1356 sel.removeAttribute('display');
1357 sel.removeAttribute('selected');
1360 li.setAttribute('display', 0);
1361 li.setAttribute('selected', '');
1363 this.closeDropdown(sb, true);
1366 this.saveValues(sb, li.parentNode);
1370 transformItem: function(sb, li) {
1371 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1374 while (li.firstChild)
1375 label.appendChild(li.firstChild);
1377 li.appendChild(cbox);
1378 li.appendChild(label);
1382 saveValues: function(sb, ul) {
1383 var sel = ul.querySelectorAll('li[selected]'),
1384 div = sb.lastElementChild,
1385 name = this.options.name,
1389 while (div.lastElementChild)
1390 div.removeChild(div.lastElementChild);
1392 sel.forEach(function (s) {
1393 if (s.hasAttribute('placeholder'))
1398 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1402 div.appendChild(E('input', {
1410 strval += strval.length ? ' ' + v.value : v.value;
1418 if (this.options.multiple)
1419 detail.values = values;
1421 detail.value = values.length ? values[0] : null;
1425 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1432 setValues: function(sb, values) {
1433 var ul = sb.querySelector('ul');
1435 if (this.options.create) {
1436 for (var value in values) {
1437 this.createItems(sb, value);
1439 if (!this.options.multiple)
1444 if (this.options.multiple) {
1445 var lis = ul.querySelectorAll('li[data-value]');
1446 for (var i = 0; i < lis.length; i++) {
1447 var value = lis[i].getAttribute('data-value');
1448 if (values === null || !(value in values))
1449 this.toggleItem(sb, lis[i], false);
1451 this.toggleItem(sb, lis[i], true);
1455 var ph = ul.querySelector('li[placeholder]');
1457 this.toggleItem(sb, ph);
1459 var lis = ul.querySelectorAll('li[data-value]');
1460 for (var i = 0; i < lis.length; i++) {
1461 var value = lis[i].getAttribute('data-value');
1462 if (values !== null && (value in values))
1463 this.toggleItem(sb, lis[i]);
1469 setFocus: function(sb, elem, scroll) {
1470 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1473 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1476 document.querySelectorAll('.focus').forEach(function(e) {
1477 if (!matchesElem(e, 'input')) {
1478 e.classList.remove('focus');
1485 elem.classList.add('focus');
1488 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1493 createChoiceElement: function(sb, value, label) {
1494 var tpl = sb.querySelector(this.options.create_template),
1498 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1500 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1502 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1503 placeholder = new_item.querySelector('[data-label-placeholder]');
1506 var content = E('span', {}, label || this.choices[value] || [ value ]);
1508 while (content.firstChild)
1509 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1511 placeholder.parentNode.removeChild(placeholder);
1514 if (this.options.multiple)
1515 this.transformItem(sb, new_item);
1521 createItems: function(sb, value) {
1523 val = (value || '').trim(),
1524 ul = sb.querySelector('ul');
1526 if (!sbox.options.multiple)
1527 val = val.length ? [ val ] : [];
1529 val = val.length ? val.split(/\s+/) : [];
1531 val.forEach(function(item) {
1532 var new_item = null;
1534 ul.childNodes.forEach(function(li) {
1535 if (li.getAttribute && li.getAttribute('data-value') === item)
1540 new_item = sbox.createChoiceElement(sb, item);
1542 if (!sbox.options.multiple) {
1543 var old = ul.querySelector('li[created]');
1545 ul.removeChild(old);
1547 new_item.setAttribute('created', '');
1550 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1553 sbox.toggleItem(sb, new_item, true);
1554 sbox.setFocus(sb, new_item, true);
1559 * Remove all existing choices from the dropdown menu.
1561 * This function removes all preexisting dropdown choices from the widget,
1562 * keeping only choices currently being selected unless `reset_values` is
1563 * given, in which case all choices and deselected and removed.
1566 * @memberof LuCI.ui.Dropdown
1567 * @param {boolean} [reset_value=false]
1568 * If set to `true`, deselect and remove selected choices as well instead
1571 clearChoices: function(reset_value) {
1572 var ul = this.node.querySelector('ul'),
1573 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1574 len = lis.length - (this.options.create ? 1 : 0),
1575 val = reset_value ? null : this.getValue();
1577 for (var i = 0; i < len; i++) {
1578 var lival = lis[i].getAttribute('data-value');
1580 (!this.options.multiple && val != lival) ||
1581 (this.options.multiple && val.indexOf(lival) == -1))
1582 ul.removeChild(lis[i]);
1586 this.setValues(this.node, {});
1590 * Add new choices to the dropdown menu.
1592 * This function adds further choices to an existing dropdown menu,
1593 * ignoring choice values which are already present.
1596 * @memberof LuCI.ui.Dropdown
1597 * @param {string[]} values
1598 * The choice values to add to the dropdown widget.
1600 * @param {Object<string, *>} labels
1601 * The choice label values to use when adding dropdown choices. If no
1602 * label is found for a particular choice value, the value itself is used
1603 * as label text. Choice labels may be any valid value accepted by
1604 * {@link LuCI.dom#content}.
1606 addChoices: function(values, labels) {
1608 ul = sb.querySelector('ul'),
1609 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1611 if (!Array.isArray(values))
1612 values = L.toArray(values);
1614 if (!L.isObject(labels))
1617 for (var i = 0; i < values.length; i++) {
1620 for (var j = 0; j < lis.length; j++) {
1621 if (lis[j].getAttribute('data-value') === values[i]) {
1631 this.createChoiceElement(sb, values[i], labels[values[i]]),
1632 ul.lastElementChild);
1637 * Close all open dropdown widgets in the current document.
1639 closeAllDropdowns: function() {
1640 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1641 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1646 handleClick: function(ev) {
1647 var sb = ev.currentTarget;
1649 if (!sb.hasAttribute('open')) {
1650 if (!matchesElem(ev.target, 'input'))
1651 this.openDropdown(sb);
1654 var li = findParent(ev.target, 'li');
1655 if (li && li.parentNode.classList.contains('dropdown'))
1656 this.toggleItem(sb, li);
1657 else if (li && li.parentNode.classList.contains('preview'))
1658 this.closeDropdown(sb);
1659 else if (matchesElem(ev.target, 'span.open, span.more'))
1660 this.closeDropdown(sb);
1663 ev.preventDefault();
1664 ev.stopPropagation();
1668 handleKeydown: function(ev) {
1669 var sb = ev.currentTarget;
1671 if (matchesElem(ev.target, 'input'))
1674 if (!sb.hasAttribute('open')) {
1675 switch (ev.keyCode) {
1680 this.openDropdown(sb);
1681 ev.preventDefault();
1685 var active = findParent(document.activeElement, 'li');
1687 switch (ev.keyCode) {
1689 this.closeDropdown(sb);
1694 if (!active.hasAttribute('selected'))
1695 this.toggleItem(sb, active);
1696 this.closeDropdown(sb);
1697 ev.preventDefault();
1703 this.toggleItem(sb, active);
1704 ev.preventDefault();
1709 if (active && active.previousElementSibling) {
1710 this.setFocus(sb, active.previousElementSibling);
1711 ev.preventDefault();
1716 if (active && active.nextElementSibling) {
1717 this.setFocus(sb, active.nextElementSibling);
1718 ev.preventDefault();
1726 handleDropdownClose: function(ev) {
1727 var sb = ev.currentTarget;
1729 this.closeDropdown(sb, true);
1733 handleDropdownSelect: function(ev) {
1734 var sb = ev.currentTarget,
1735 li = findParent(ev.target, 'li');
1740 this.toggleItem(sb, li);
1741 this.closeDropdown(sb, true);
1745 handleMouseover: function(ev) {
1746 var sb = ev.currentTarget;
1748 if (!sb.hasAttribute('open'))
1751 var li = findParent(ev.target, 'li');
1753 if (li && li.parentNode.classList.contains('dropdown'))
1754 this.setFocus(sb, li);
1758 handleFocus: function(ev) {
1759 var sb = ev.currentTarget;
1761 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1762 if (s !== sb || sb.hasAttribute('open'))
1763 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1768 handleCanaryFocus: function(ev) {
1769 this.closeDropdown(ev.currentTarget.parentNode);
1773 handleCreateKeydown: function(ev) {
1774 var input = ev.currentTarget,
1775 sb = findParent(input, '.cbi-dropdown');
1777 switch (ev.keyCode) {
1779 ev.preventDefault();
1781 if (input.classList.contains('cbi-input-invalid'))
1784 this.createItems(sb, input.value);
1792 handleCreateFocus: function(ev) {
1793 var input = ev.currentTarget,
1794 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1795 sb = findParent(input, '.cbi-dropdown');
1798 cbox.checked = true;
1800 sb.setAttribute('locked-in', '');
1804 handleCreateBlur: function(ev) {
1805 var input = ev.currentTarget,
1806 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1807 sb = findParent(input, '.cbi-dropdown');
1810 cbox.checked = false;
1812 sb.removeAttribute('locked-in');
1816 handleCreateClick: function(ev) {
1817 ev.currentTarget.querySelector(this.options.create_query).focus();
1821 setValue: function(values) {
1822 if (this.options.multiple) {
1823 if (!Array.isArray(values))
1824 values = (values != null && values != '') ? [ values ] : [];
1828 for (var i = 0; i < values.length; i++)
1829 v[values[i]] = true;
1831 this.setValues(this.node, v);
1836 if (values != null) {
1837 if (Array.isArray(values))
1838 v[values[0]] = true;
1843 this.setValues(this.node, v);
1848 getValue: function() {
1849 var div = this.node.lastElementChild,
1850 h = div.querySelectorAll('input[type="hidden"]'),
1853 for (var i = 0; i < h.length; i++)
1856 return this.options.multiple ? v : v[0];
1861 * Instantiate a rich dropdown choice widget allowing custom values.
1863 * @constructor Combobox
1865 * @augments LuCI.ui.Dropdown
1869 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1870 * to enter custom values. Historically, comboboxes used to be a dedicated
1871 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1872 * with a set of enforced default properties for easier instantiation.
1874 * UI widget instances are usually not supposed to be created by view code
1875 * directly, instead they're implicitely created by `LuCI.form` when
1876 * instantiating CBI forms.
1878 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1879 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1880 * external JavaScript, use `L.require("ui").then(...)` and access the
1881 * `Combobox` property of the class instance value.
1883 * @param {string|string[]} [value=null]
1884 * The initial input value(s).
1886 * @param {Object<string, *>} choices
1887 * Object containing the selectable choices of the widget. The object keys
1888 * serve as values for the different choices while the values are used as
1891 * @param {LuCI.ui.Combobox.InitOptions} [options]
1892 * Object describing the widget specific options to initialize the dropdown.
1894 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1896 * Comboboxes support the same properties as
1897 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1898 * specific values for the following properties:
1900 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1901 * @memberof LuCI.ui.Combobox
1903 * @property {boolean} multiple=false
1904 * Since Comboboxes never allow selecting multiple values, this property
1905 * is forcibly set to `false`.
1907 * @property {boolean} create=true
1908 * Since Comboboxes always allow custom choice values, this property is
1909 * forcibly set to `true`.
1911 * @property {boolean} optional=true
1912 * Since Comboboxes are always optional, this property is forcibly set to
1915 __init__: function(value, choices, options) {
1916 this.super('__init__', [ value, choices, Object.assign({
1917 select_placeholder: _('-- Please choose --'),
1918 custom_placeholder: _('-- custom --'),
1930 * Instantiate a combo button widget offering multiple action choices.
1932 * @constructor ComboButton
1934 * @augments LuCI.ui.Dropdown
1938 * The `ComboButton` class implements a button element which can be expanded
1939 * into a dropdown to chose from a set of different action choices.
1941 * UI widget instances are usually not supposed to be created by view code
1942 * directly, instead they're implicitely created by `LuCI.form` when
1943 * instantiating CBI forms.
1945 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1946 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1947 * external JavaScript, use `L.require("ui").then(...)` and access the
1948 * `ComboButton` property of the class instance value.
1950 * @param {string|string[]} [value=null]
1951 * The initial input value(s).
1953 * @param {Object<string, *>} choices
1954 * Object containing the selectable choices of the widget. The object keys
1955 * serve as values for the different choices while the values are used as
1958 * @param {LuCI.ui.ComboButton.InitOptions} [options]
1959 * Object describing the widget specific options to initialize the button.
1961 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1963 * ComboButtons support the same properties as
1964 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1965 * specific values for some properties and add aditional button specific
1968 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1969 * @memberof LuCI.ui.ComboButton
1971 * @property {boolean} multiple=false
1972 * Since ComboButtons never allow selecting multiple actions, this property
1973 * is forcibly set to `false`.
1975 * @property {boolean} create=false
1976 * Since ComboButtons never allow creating custom choices, this property
1977 * is forcibly set to `false`.
1979 * @property {boolean} optional=false
1980 * Since ComboButtons must always select one action, this property is
1981 * forcibly set to `false`.
1983 * @property {Object<string, string>} [classes]
1984 * Specifies a mapping of choice values to CSS class names. If an action
1985 * choice is selected by the user and if a corresponding entry exists in
1986 * the `classes` object, the class names corresponding to the selected
1987 * value are set on the button element.
1989 * This is useful to apply different button styles, such as colors, to the
1990 * combined button depending on the selected action.
1992 * @property {function} [click]
1993 * Specifies a handler function to invoke when the user clicks the button.
1994 * This function will be called with the button DOM node as `this` context
1995 * and receive the DOM click event as first as well as the selected action
1996 * choice value as second argument.
1998 __init__: function(value, choices, options) {
1999 this.super('__init__', [ value, choices, Object.assign({
2009 render: function(/* ... */) {
2010 var node = UIDropdown.prototype.render.apply(this, arguments),
2011 val = this.getValue();
2013 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2014 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2020 handleClick: function(ev) {
2021 var sb = ev.currentTarget,
2024 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2025 return UIDropdown.prototype.handleClick.apply(this, arguments);
2027 if (this.options.click)
2028 return this.options.click.call(sb, ev, this.getValue());
2032 toggleItem: function(sb /*, ... */) {
2033 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2034 val = this.getValue();
2036 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2037 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2039 sb.setAttribute('class', 'cbi-dropdown');
2046 * Instantiate a dynamic list widget.
2048 * @constructor DynamicList
2050 * @augments LuCI.ui.AbstractElement
2054 * The `DynamicList` class implements a widget which allows the user to specify
2055 * an arbitrary amount of input values, either from free formed text input or
2056 * from a set of predefined choices.
2058 * UI widget instances are usually not supposed to be created by view code
2059 * directly, instead they're implicitely created by `LuCI.form` when
2060 * instantiating CBI forms.
2062 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2063 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2064 * external JavaScript, use `L.require("ui").then(...)` and access the
2065 * `DynamicList` property of the class instance value.
2067 * @param {string|string[]} [value=null]
2068 * The initial input value(s).
2070 * @param {Object<string, *>} [choices]
2071 * Object containing the selectable choices of the widget. The object keys
2072 * serve as values for the different choices while the values are used as
2073 * choice labels. If omitted, no default choices are presented to the user,
2074 * instead a plain text input field is rendered allowing the user to add
2075 * arbitrary values to the dynamic list.
2077 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2078 * Object describing the widget specific options to initialize the dynamic list.
2080 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2082 * In case choices are passed to the dynamic list contructor, the widget
2083 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2084 * but enforces specific values for some dropdown properties.
2086 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2087 * @memberof LuCI.ui.DynamicList
2089 * @property {boolean} multiple=false
2090 * Since dynamic lists never allow selecting multiple choices when adding
2091 * another list item, this property is forcibly set to `false`.
2093 * @property {boolean} optional=true
2094 * Since dynamic lists use an embedded dropdown to present a list of
2095 * predefined choice values, the dropdown must be made optional to allow
2096 * it to remain unselected.
2098 __init__: function(values, choices, options) {
2099 if (!Array.isArray(values))
2100 values = (values != null && values != '') ? [ values ] : [];
2102 if (typeof(choices) != 'object')
2105 this.values = values;
2106 this.choices = choices;
2107 this.options = Object.assign({}, options, {
2114 render: function() {
2116 'id': this.options.id,
2117 'class': 'cbi-dynlist'
2118 }, E('div', { 'class': 'add-item' }));
2121 if (this.options.placeholder != null)
2122 this.options.select_placeholder = this.options.placeholder;
2124 var cbox = new UICombobox(null, this.choices, this.options);
2126 dl.lastElementChild.appendChild(cbox.render());
2129 var inputEl = E('input', {
2130 'id': this.options.id ? 'widget.' + this.options.id : null,
2132 'class': 'cbi-input-text',
2133 'placeholder': this.options.placeholder
2136 dl.lastElementChild.appendChild(inputEl);
2137 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2139 if (this.options.datatype || this.options.validate)
2140 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2141 true, this.options.validate, 'blur', 'keyup');
2144 for (var i = 0; i < this.values.length; i++) {
2145 var label = this.choices ? this.choices[this.values[i]] : null;
2147 if (dom.elem(label))
2148 label = label.cloneNode(true);
2150 this.addItem(dl, this.values[i], label);
2153 return this.bind(dl);
2157 bind: function(dl) {
2158 dl.addEventListener('click', L.bind(this.handleClick, this));
2159 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2160 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2164 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2165 this.setChangeEvents(dl, 'cbi-dynlist-change');
2167 dom.bindClassInstance(dl, this);
2173 addItem: function(dl, value, text, flash) {
2175 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2176 E('span', {}, [ text || value ]),
2179 'name': this.options.name,
2180 'value': value })]);
2182 dl.querySelectorAll('.item').forEach(function(item) {
2186 var hidden = item.querySelector('input[type="hidden"]');
2188 if (hidden && hidden.parentNode !== item)
2191 if (hidden && hidden.value === value)
2196 var ai = dl.querySelector('.add-item');
2197 ai.parentNode.insertBefore(new_item, ai);
2200 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2212 removeItem: function(dl, item) {
2213 var value = item.querySelector('input[type="hidden"]').value;
2214 var sb = dl.querySelector('.cbi-dropdown');
2216 sb.querySelectorAll('ul > li').forEach(function(li) {
2217 if (li.getAttribute('data-value') === value) {
2218 if (li.hasAttribute('dynlistcustom'))
2219 li.parentNode.removeChild(li);
2221 li.removeAttribute('unselectable');
2225 item.parentNode.removeChild(item);
2227 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2239 handleClick: function(ev) {
2240 var dl = ev.currentTarget,
2241 item = findParent(ev.target, '.item');
2244 this.removeItem(dl, item);
2246 else if (matchesElem(ev.target, '.cbi-button-add')) {
2247 var input = ev.target.previousElementSibling;
2248 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2249 this.addItem(dl, input.value, null, true);
2256 handleDropdownChange: function(ev) {
2257 var dl = ev.currentTarget,
2258 sbIn = ev.detail.instance,
2259 sbEl = ev.detail.element,
2260 sbVal = ev.detail.value;
2265 sbIn.setValues(sbEl, null);
2266 sbVal.element.setAttribute('unselectable', '');
2268 if (sbVal.element.hasAttribute('created')) {
2269 sbVal.element.removeAttribute('created');
2270 sbVal.element.setAttribute('dynlistcustom', '');
2273 var label = sbVal.text;
2275 if (sbVal.element) {
2278 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2279 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2282 this.addItem(dl, sbVal.value, label, true);
2286 handleKeydown: function(ev) {
2287 var dl = ev.currentTarget,
2288 item = findParent(ev.target, '.item');
2291 switch (ev.keyCode) {
2292 case 8: /* backspace */
2293 if (item.previousElementSibling)
2294 item.previousElementSibling.focus();
2296 this.removeItem(dl, item);
2299 case 46: /* delete */
2300 if (item.nextElementSibling) {
2301 if (item.nextElementSibling.classList.contains('item'))
2302 item.nextElementSibling.focus();
2304 item.nextElementSibling.firstElementChild.focus();
2307 this.removeItem(dl, item);
2311 else if (matchesElem(ev.target, '.cbi-input-text')) {
2312 switch (ev.keyCode) {
2313 case 13: /* enter */
2314 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2315 this.addItem(dl, ev.target.value, null, true);
2316 ev.target.value = '';
2321 ev.preventDefault();
2328 getValue: function() {
2329 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2330 input = this.node.querySelector('.add-item > input[type="text"]'),
2333 for (var i = 0; i < items.length; i++)
2334 v.push(items[i].value);
2336 if (input && input.value != null && input.value.match(/\S/) &&
2337 input.classList.contains('cbi-input-invalid') == false &&
2338 v.filter(function(s) { return s == input.value }).length == 0)
2339 v.push(input.value);
2345 setValue: function(values) {
2346 if (!Array.isArray(values))
2347 values = (values != null && values != '') ? [ values ] : [];
2349 var items = this.node.querySelectorAll('.item');
2351 for (var i = 0; i < items.length; i++)
2352 if (items[i].parentNode === this.node)
2353 this.removeItem(this.node, items[i]);
2355 for (var i = 0; i < values.length; i++)
2356 this.addItem(this.node, values[i],
2357 this.choices ? this.choices[values[i]] : null);
2361 * Add new suggested choices to the dynamic list.
2363 * This function adds further choices to an existing dynamic list,
2364 * ignoring choice values which are already present.
2367 * @memberof LuCI.ui.DynamicList
2368 * @param {string[]} values
2369 * The choice values to add to the dynamic lists suggestion dropdown.
2371 * @param {Object<string, *>} labels
2372 * The choice label values to use when adding suggested choices. If no
2373 * label is found for a particular choice value, the value itself is used
2374 * as label text. Choice labels may be any valid value accepted by
2375 * {@link LuCI.dom#content}.
2377 addChoices: function(values, labels) {
2378 var dl = this.node.lastElementChild.firstElementChild;
2379 dom.callClassMethod(dl, 'addChoices', values, labels);
2383 * Remove all existing choices from the dynamic list.
2385 * This function removes all preexisting suggested choices from the widget.
2388 * @memberof LuCI.ui.DynamicList
2390 clearChoices: function() {
2391 var dl = this.node.lastElementChild.firstElementChild;
2392 dom.callClassMethod(dl, 'clearChoices');
2397 * Instantiate a hidden input field widget.
2399 * @constructor Hiddenfield
2401 * @augments LuCI.ui.AbstractElement
2405 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2406 * which allows to store form data without exposing it to the user.
2408 * UI widget instances are usually not supposed to be created by view code
2409 * directly, instead they're implicitely created by `LuCI.form` when
2410 * instantiating CBI forms.
2412 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2413 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2414 * external JavaScript, use `L.require("ui").then(...)` and access the
2415 * `Hiddenfield` property of the class instance value.
2417 * @param {string|string[]} [value=null]
2418 * The initial input value.
2420 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2421 * Object describing the widget specific options to initialize the hidden input.
2423 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2424 __init__: function(value, options) {
2426 this.options = Object.assign({
2432 render: function() {
2433 var hiddenEl = E('input', {
2434 'id': this.options.id,
2439 return this.bind(hiddenEl);
2443 bind: function(hiddenEl) {
2444 this.node = hiddenEl;
2446 dom.bindClassInstance(hiddenEl, this);
2452 getValue: function() {
2453 return this.node.value;
2457 setValue: function(value) {
2458 this.node.value = value;
2463 * Instantiate a file upload widget.
2465 * @constructor FileUpload
2467 * @augments LuCI.ui.AbstractElement
2471 * The `FileUpload` class implements a widget which allows the user to upload,
2472 * browse, select and delete files beneath a predefined remote directory.
2474 * UI widget instances are usually not supposed to be created by view code
2475 * directly, instead they're implicitely created by `LuCI.form` when
2476 * instantiating CBI forms.
2478 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2479 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2480 * external JavaScript, use `L.require("ui").then(...)` and access the
2481 * `FileUpload` property of the class instance value.
2483 * @param {string|string[]} [value=null]
2484 * The initial input value.
2486 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2487 * Object describing the widget specific options to initialize the file
2490 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2492 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2493 * the following properties are recognized:
2495 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2496 * @memberof LuCI.ui.FileUpload
2498 * @property {boolean} [show_hidden=false]
2499 * Specifies whether hidden files should be displayed when browsing remote
2500 * files. Note that this is not a security feature, hidden files are always
2501 * present in the remote file listings received, this option merely controls
2502 * whether they're displayed or not.
2504 * @property {boolean} [enable_upload=true]
2505 * Specifies whether the widget allows the user to upload files. If set to
2506 * `false`, only existing files may be selected. Note that this is not a
2507 * security feature. Whether file upload requests are accepted remotely
2508 * depends on the ACL setup for the current session. This option merely
2509 * controls whether the upload controls are rendered or not.
2511 * @property {boolean} [enable_remove=true]
2512 * Specifies whether the widget allows the user to delete remove files.
2513 * If set to `false`, existing files may not be removed. Note that this is
2514 * not a security feature. Whether file delete requests are accepted
2515 * remotely depends on the ACL setup for the current session. This option
2516 * merely controls whether the file remove controls are rendered or not.
2518 * @property {string} [root_directory=/etc/luci-uploads]
2519 * Specifies the remote directory the upload and file browsing actions take
2520 * place in. Browsing to directories outside of the root directory is
2521 * prevented by the widget. Note that this is not a security feature.
2522 * Whether remote directories are browseable or not solely depends on the
2523 * ACL setup for the current session.
2525 __init__: function(value, options) {
2527 this.options = Object.assign({
2529 enable_upload: true,
2530 enable_remove: true,
2531 root_directory: '/etc/luci-uploads'
2536 bind: function(browserEl) {
2537 this.node = browserEl;
2539 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2540 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2542 dom.bindClassInstance(browserEl, this);
2548 render: function() {
2549 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2552 if (L.isObject(stat) && stat.type != 'directory')
2555 if (this.stat != null)
2556 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2557 else if (this.value != null)
2558 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2560 label = [ _('Select file…') ];
2562 return this.bind(E('div', { 'id': this.options.id }, [
2565 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser')
2568 'class': 'cbi-filebrowser'
2572 'name': this.options.name,
2580 truncatePath: function(path) {
2581 if (path.length > 50)
2582 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2588 iconForType: function(type) {
2592 'src': L.resource('cbi/link.gif'),
2593 'title': _('Symbolic link'),
2599 'src': L.resource('cbi/folder.gif'),
2600 'title': _('Directory'),
2606 'src': L.resource('cbi/file.gif'),
2614 canonicalizePath: function(path) {
2615 return path.replace(/\/{2,}/, '/')
2616 .replace(/\/\.(\/|$)/g, '/')
2617 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2618 .replace(/\/$/, '');
2622 splitPath: function(path) {
2623 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2624 cpath = this.canonicalizePath(path || '/');
2626 if (cpath.length <= croot.length)
2629 if (cpath.charAt(croot.length) != '/')
2632 var parts = cpath.substring(croot.length + 1).split(/\//);
2634 parts.unshift(croot);
2640 handleUpload: function(path, list, ev) {
2641 var form = ev.target.parentNode,
2642 fileinput = form.querySelector('input[type="file"]'),
2643 nameinput = form.querySelector('input[type="text"]'),
2644 filename = (nameinput.value != null ? nameinput.value : '').trim();
2646 ev.preventDefault();
2648 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2651 var existing = list.filter(function(e) { return e.name == filename })[0];
2653 if (existing != null && existing.type == 'directory')
2654 return alert(_('A directory with the same name already exists.'));
2655 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2658 var data = new FormData();
2660 data.append('sessionid', L.env.sessionid);
2661 data.append('filename', path + '/' + filename);
2662 data.append('filedata', fileinput.files[0]);
2664 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2665 progress: L.bind(function(btn, ev) {
2666 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2668 }).then(L.bind(function(path, ev, res) {
2669 var reply = res.json();
2671 if (L.isObject(reply) && reply.failure)
2672 alert(_('Upload request failed: %s').format(reply.message));
2674 return this.handleSelect(path, null, ev);
2675 }, this, path, ev));
2679 handleDelete: function(path, fileStat, ev) {
2680 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2681 name = path.replace(/^.+\//, ''),
2684 ev.preventDefault();
2686 if (fileStat.type == 'directory')
2687 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2689 msg = _('Do you really want to delete "%s" ?').format(name);
2692 var button = this.node.firstElementChild,
2693 hidden = this.node.lastElementChild;
2695 if (path == hidden.value) {
2696 dom.content(button, _('Select file…'));
2700 return fs.remove(path).then(L.bind(function(parent, ev) {
2701 return this.handleSelect(parent, null, ev);
2702 }, this, parent, ev)).catch(function(err) {
2703 alert(_('Delete request failed: %s').format(err.message));
2709 renderUpload: function(path, list) {
2710 if (!this.options.enable_upload)
2716 'class': 'btn cbi-button-positive',
2717 'click': function(ev) {
2718 var uploadForm = ev.target.nextElementSibling,
2719 fileInput = uploadForm.querySelector('input[type="file"]');
2721 ev.target.style.display = 'none';
2722 uploadForm.style.display = '';
2725 }, _('Upload file…')),
2726 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2729 'style': 'display:none',
2730 'change': function(ev) {
2731 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2732 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2734 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2735 uploadbtn.disabled = false;
2740 'click': function(ev) {
2741 ev.preventDefault();
2742 ev.target.previousElementSibling.click();
2744 }, [ _('Browse…') ]),
2745 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2747 'class': 'btn cbi-button-save',
2748 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2750 }, [ _('Upload file') ])
2756 renderListing: function(container, path, list) {
2757 var breadcrumb = E('p'),
2760 list.sort(function(a, b) {
2761 var isDirA = (a.type == 'directory'),
2762 isDirB = (b.type == 'directory');
2764 if (isDirA != isDirB)
2765 return isDirA < isDirB;
2767 return a.name > b.name;
2770 for (var i = 0; i < list.length; i++) {
2771 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2774 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2775 selected = (entrypath == this.node.lastElementChild.value),
2776 mtime = new Date(list[i].mtime * 1000);
2778 rows.appendChild(E('li', [
2779 E('div', { 'class': 'name' }, [
2780 this.iconForType(list[i].type),
2784 'style': selected ? 'font-weight:bold' : null,
2785 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2786 entrypath, list[i].type != 'directory' ? list[i] : null)
2787 }, '%h'.format(list[i].name))
2789 E('div', { 'class': 'mtime hide-xs' }, [
2790 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2791 mtime.getFullYear(),
2792 mtime.getMonth() + 1,
2799 selected ? E('button', {
2801 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2802 }, [ _('Deselect') ]) : '',
2803 this.options.enable_remove ? E('button', {
2804 'class': 'btn cbi-button-negative',
2805 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2806 }, [ _('Delete') ]) : ''
2811 if (!rows.firstElementChild)
2812 rows.appendChild(E('em', _('No entries in this directory')));
2814 var dirs = this.splitPath(path),
2817 for (var i = 0; i < dirs.length; i++) {
2818 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2819 dom.append(breadcrumb, [
2823 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2824 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2828 dom.content(container, [
2831 E('div', { 'class': 'right' }, [
2832 this.renderUpload(path, list),
2836 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2843 handleCancel: function(ev) {
2844 var button = this.node.firstElementChild,
2845 browser = button.nextElementSibling;
2847 browser.classList.remove('open');
2848 button.style.display = '';
2850 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2852 ev.preventDefault();
2856 handleReset: function(ev) {
2857 var button = this.node.firstElementChild,
2858 hidden = this.node.lastElementChild;
2861 dom.content(button, _('Select file…'));
2863 this.handleCancel(ev);
2867 handleSelect: function(path, fileStat, ev) {
2868 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2869 ul = browser.querySelector('ul');
2871 if (fileStat == null) {
2872 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2873 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2876 var button = this.node.firstElementChild,
2877 hidden = this.node.lastElementChild;
2879 path = this.canonicalizePath(path);
2881 dom.content(button, [
2882 this.iconForType(fileStat.type),
2883 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2886 browser.classList.remove('open');
2887 button.style.display = '';
2888 hidden.value = path;
2890 this.stat = Object.assign({ path: path }, fileStat);
2891 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2896 handleFileBrowser: function(ev) {
2897 var button = ev.target,
2898 browser = button.nextElementSibling,
2899 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2901 if (path.indexOf(this.options.root_directory) != 0)
2902 path = this.options.root_directory;
2904 ev.preventDefault();
2906 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2907 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2908 dom.findClassInstance(browserEl).handleCancel(ev);
2911 button.style.display = 'none';
2912 browser.classList.add('open');
2914 return this.renderListing(browser, path, list);
2915 }, this, button, browser, path));
2919 getValue: function() {
2920 return this.node.lastElementChild.value;
2924 setValue: function(value) {
2925 this.node.lastElementChild.value = value;
2935 * Provides high level UI helper functionality.
2936 * To import the class in views, use `'require ui'`, to import it in
2937 * external JavaScript, use `L.require("ui").then(...)`.
2939 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
2940 __init__: function() {
2941 modalDiv = document.body.appendChild(
2942 dom.create('div', { id: 'modal_overlay' },
2943 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
2945 tooltipDiv = document.body.appendChild(
2946 dom.create('div', { class: 'cbi-tooltip' }));
2948 /* setup old aliases */
2949 L.showModal = this.showModal;
2950 L.hideModal = this.hideModal;
2951 L.showTooltip = this.showTooltip;
2952 L.hideTooltip = this.hideTooltip;
2953 L.itemlist = this.itemlist;
2955 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
2956 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
2957 document.addEventListener('focus', this.showTooltip.bind(this), true);
2958 document.addEventListener('blur', this.hideTooltip.bind(this), true);
2960 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
2961 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
2962 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
2966 * Display a modal overlay dialog with the specified contents.
2968 * The modal overlay dialog covers the current view preventing interaction
2969 * with the underlying view contents. Only one modal dialog instance can
2970 * be opened. Invoking showModal() while a modal dialog is already open will
2971 * replace the open dialog with a new one having the specified contents.
2973 * Additional CSS class names may be passed to influence the appearence of
2974 * the dialog. Valid values for the classes depend on the underlying theme.
2976 * @see LuCI.dom.content
2978 * @param {string} [title]
2979 * The title of the dialog. If `null`, no title element will be rendered.
2981 * @param {*} contents
2982 * The contents to add to the modal dialog. This should be a DOM node or
2983 * a document fragment in most cases. The value is passed as-is to the
2984 * `dom.content()` function - refer to its documentation for applicable
2987 * @param {...string} [classes]
2988 * A number of extra CSS class names which are set on the modal dialog
2992 * Returns a DOM Node representing the modal dialog element.
2994 showModal: function(title, children /* , ... */) {
2995 var dlg = modalDiv.firstElementChild;
2997 dlg.setAttribute('class', 'modal');
2999 for (var i = 2; i < arguments.length; i++)
3000 dlg.classList.add(arguments[i]);
3002 dom.content(dlg, dom.create('h4', {}, title));
3003 dom.append(dlg, children);
3005 document.body.classList.add('modal-overlay-active');
3011 * Close the open modal overlay dialog.
3013 * This function will close an open modal dialog and restore the normal view
3014 * behaviour. It has no effect if no modal dialog is currently open.
3016 * Note that this function is stand-alone, it does not rely on `this` and
3017 * will not invoke other class functions so it suitable to be used as event
3018 * handler as-is without the need to bind it first.
3020 hideModal: function() {
3021 document.body.classList.remove('modal-overlay-active');
3025 showTooltip: function(ev) {
3026 var target = findParent(ev.target, '[data-tooltip]');
3031 if (tooltipTimeout !== null) {
3032 window.clearTimeout(tooltipTimeout);
3033 tooltipTimeout = null;
3036 var rect = target.getBoundingClientRect(),
3037 x = rect.left + window.pageXOffset,
3038 y = rect.top + rect.height + window.pageYOffset;
3040 tooltipDiv.className = 'cbi-tooltip';
3041 tooltipDiv.innerHTML = '▲ ';
3042 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3044 if (target.hasAttribute('data-tooltip-style'))
3045 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3047 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3048 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3049 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
3052 tooltipDiv.style.top = y + 'px';
3053 tooltipDiv.style.left = x + 'px';
3054 tooltipDiv.style.opacity = 1;
3056 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3058 detail: { target: target }
3063 hideTooltip: function(ev) {
3064 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3065 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3068 if (tooltipTimeout !== null) {
3069 window.clearTimeout(tooltipTimeout);
3070 tooltipTimeout = null;
3073 tooltipDiv.style.opacity = 0;
3074 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3076 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3080 * Add a notification banner at the top of the current view.
3082 * A notification banner is an alert message usually displayed at the
3083 * top of the current view, spanning the entire availibe width.
3084 * Notification banners will stay in place until dismissed by the user.
3085 * Multiple banners may be shown at the same time.
3087 * Additional CSS class names may be passed to influence the appearence of
3088 * the banner. Valid values for the classes depend on the underlying theme.
3090 * @see LuCI.dom.content
3092 * @param {string} [title]
3093 * The title of the notification banner. If `null`, no title element
3096 * @param {*} contents
3097 * The contents to add to the notification banner. This should be a DOM
3098 * node or a document fragment in most cases. The value is passed as-is
3099 * to the `dom.content()` function - refer to its documentation for
3100 * applicable values.
3102 * @param {...string} [classes]
3103 * A number of extra CSS class names which are set on the notification
3107 * Returns a DOM Node representing the notification banner element.
3109 addNotification: function(title, children /*, ... */) {
3110 var mc = document.querySelector('#maincontent') || document.body;
3111 var msg = E('div', {
3112 'class': 'alert-message fade-in',
3113 'style': 'display:flex',
3114 'transitionend': function(ev) {
3115 var node = ev.currentTarget;
3116 if (node.parentNode && node.classList.contains('fade-out'))
3117 node.parentNode.removeChild(node);
3120 E('div', { 'style': 'flex:10' }),
3121 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3124 'style': 'margin-left:auto; margin-top:auto',
3125 'click': function(ev) {
3126 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3129 }, [ _('Dismiss') ])
3134 dom.append(msg.firstElementChild, E('h4', {}, title));
3136 dom.append(msg.firstElementChild, children);
3138 for (var i = 2; i < arguments.length; i++)
3139 msg.classList.add(arguments[i]);
3141 mc.insertBefore(msg, mc.firstElementChild);
3147 * Display or update an header area indicator.
3149 * An indicator is a small label displayed in the header area of the screen
3150 * providing few amounts of status information such as item counts or state
3151 * toggle indicators.
3153 * Multiple indicators may be shown at the same time and indicator labels
3154 * may be made clickable to display extended information or to initiate
3157 * Indicators can either use a default `active` or a less accented `inactive`
3158 * style which is useful for indicators representing state toggles.
3160 * @param {string} id
3161 * The ID of the indicator. If an indicator with the given ID already exists,
3162 * it is updated with the given label and style.
3164 * @param {string} label
3165 * The text to display in the indicator label.
3167 * @param {function} [handler]
3168 * A handler function to invoke when the indicator label is clicked/touched
3169 * by the user. If omitted, the indicator is not clickable/touchable.
3171 * Note that this parameter only applies to new indicators, when updating
3172 * existing labels it is ignored.
3174 * @param {string} [style=active]
3175 * The indicator style to use. May be either `active` or `inactive`.
3177 * @returns {boolean}
3178 * Returns `true` when the indicator has been updated or `false` when no
3179 * changes were made.
3181 showIndicator: function(id, label, handler, style) {
3182 if (indicatorDiv == null) {
3183 indicatorDiv = document.body.querySelector('#indicators');
3185 if (indicatorDiv == null)
3189 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3190 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) ||
3191 indicatorDiv.appendChild(E('span', {
3192 'data-indicator': id,
3193 'data-clickable': handlerFn ? true : null,
3197 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3200 indicatorElem.firstChild.data = label;
3201 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3206 * Remove an header area indicator.
3208 * This function removes the given indicator label from the header indicator
3209 * area. When the given indicator is not found, this function does nothing.
3211 * @param {string} id
3212 * The ID of the indicator to remove.
3214 * @returns {boolean}
3215 * Returns `true` when the indicator has been removed or `false` when the
3216 * requested indicator was not found.
3218 hideIndicator: function(id) {
3219 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3221 if (indicatorElem == null)
3224 indicatorDiv.removeChild(indicatorElem);
3229 * Formats a series of label/value pairs into list-like markup.
3231 * This function transforms a flat array of alternating label and value
3232 * elements into a list-like markup, using the values in `separators` as
3233 * separators and appends the resulting nodes to the given parent DOM node.
3235 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3236 * `<strong>` element and the value corresponding to the label are
3237 * subsequently wrapped into a `<span class="nowrap">` element.
3239 * The resulting `<span>` element tuples are joined by the given separators
3240 * to form the final markup which is appened to the given parent DOM node.
3242 * @param {Node} node
3243 * The parent DOM node to append the markup to. Any previous child elements
3246 * @param {Array<*>} items
3247 * An alternating array of labels and values. The label values will be
3248 * converted to plain strings, the values are used as-is and may be of
3249 * any type accepted by `LuCI.dom.content()`.
3251 * @param {*|Array<*>} [separators=[E('br')]]
3252 * A single value or an array of separator values to separate each
3253 * label/value pair with. The function will cycle through the separators
3254 * when joining the pairs. If omitted, the default separator is a sole HTML
3255 * `<br>` element. Separator values are used as-is and may be of any type
3256 * accepted by `LuCI.dom.content()`.
3259 * Returns the parent DOM node the formatted markup has been added to.
3261 itemlist: function(node, items, separators) {
3264 if (!Array.isArray(separators))
3265 separators = [ separators || E('br') ];
3267 for (var i = 0; i < items.length; i += 2) {
3268 if (items[i+1] !== null && items[i+1] !== undefined) {
3269 var sep = separators[(i/2) % separators.length],
3272 children.push(E('span', { class: 'nowrap' }, [
3273 items[i] ? E('strong', items[i] + ': ') : '',
3277 if ((i+2) < items.length)
3278 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3282 dom.content(node, children);
3293 * The `tabs` class handles tab menu groups used throughout the view area.
3294 * It takes care of setting up tab groups, tracking their state and handling
3297 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3298 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3299 * external JavaScript, use `L.require("ui").then(...)` and access the
3300 * `tabs` property of the class instance value.
3302 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3305 var groups = [], prevGroup = null, currGroup = null;
3307 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3308 var parent = tab.parentNode;
3310 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3313 if (!parent.hasAttribute('data-tab-group'))
3314 parent.setAttribute('data-tab-group', groups.length);
3316 currGroup = +parent.getAttribute('data-tab-group');
3318 if (currGroup !== prevGroup) {
3319 prevGroup = currGroup;
3321 if (!groups[currGroup])
3322 groups[currGroup] = [];
3325 groups[currGroup].push(tab);
3328 for (var i = 0; i < groups.length; i++)
3329 this.initTabGroup(groups[i]);
3331 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3337 * Initializes a new tab group from the given tab pane collection.
3339 * This function cycles through the given tab pane DOM nodes, extracts
3340 * their tab IDs, titles and active states, renders a corresponding
3341 * tab menu and prepends it to the tab panes common parent DOM node.
3343 * The tab menu labels will be set to the value of the `data-tab-title`
3344 * attribute of each corresponding pane. The last pane with the
3345 * `data-tab-active` attribute set to `true` will be selected by default.
3347 * If no pane is marked as active, the first one will be preselected.
3350 * @memberof LuCI.ui.tabs
3351 * @param {Array<Node>|NodeList} panes
3352 * A collection of tab panes to build a tab group menu for. May be a
3353 * plain array of DOM nodes or a NodeList collection, such as the result
3354 * of a `querySelectorAll()` call or the `.childNodes` property of a
3357 initTabGroup: function(panes) {
3358 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3361 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3362 group = panes[0].parentNode,
3363 groupId = +group.getAttribute('data-tab-group'),
3366 if (group.getAttribute('data-initialized') === 'true')
3369 for (var i = 0, pane; pane = panes[i]; i++) {
3370 var name = pane.getAttribute('data-tab'),
3371 title = pane.getAttribute('data-tab-title'),
3372 active = pane.getAttribute('data-tab-active') === 'true';
3374 menu.appendChild(E('li', {
3375 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3376 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3380 'click': this.switchTab.bind(this)
3387 group.parentNode.insertBefore(menu, group);
3388 group.setAttribute('data-initialized', true);
3390 if (selected === null) {
3391 selected = this.getActiveTabId(panes[0]);
3393 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3394 for (var i = 0; i < panes.length; i++) {
3395 if (!this.isEmptyPane(panes[i])) {
3402 menu.childNodes[selected].classList.add('cbi-tab');
3403 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3404 panes[selected].setAttribute('data-tab-active', 'true');
3406 this.setActiveTabId(panes[selected], selected);
3409 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3410 detail: { tab: panes[selected].getAttribute('data-tab') }
3413 this.updateTabs(group);
3417 * Checks whether the given tab pane node is empty.
3420 * @memberof LuCI.ui.tabs
3421 * @param {Node} pane
3422 * The tab pane to check.
3424 * @returns {boolean}
3425 * Returns `true` if the pane is empty, else `false`.
3427 isEmptyPane: function(pane) {
3428 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3432 getPathForPane: function(pane) {
3433 var path = [], node = null;
3435 for (node = pane ? pane.parentNode : null;
3436 node != null && node.hasAttribute != null;
3437 node = node.parentNode)
3439 if (node.hasAttribute('data-tab'))
3440 path.unshift(node.getAttribute('data-tab'));
3441 else if (node.hasAttribute('data-section-id'))
3442 path.unshift(node.getAttribute('data-section-id'));
3445 return path.join('/');
3449 getActiveTabState: function() {
3450 var page = document.body.getAttribute('data-page');
3453 var val = JSON.parse(window.sessionStorage.getItem('tab'));
3454 if (val.page === page && L.isObject(val.paths))
3459 window.sessionStorage.removeItem('tab');
3460 return { page: page, paths: {} };
3464 getActiveTabId: function(pane) {
3465 var path = this.getPathForPane(pane);
3466 return +this.getActiveTabState().paths[path] || 0;
3470 setActiveTabId: function(pane, tabIndex) {
3471 var path = this.getPathForPane(pane);
3474 var state = this.getActiveTabState();
3475 state.paths[path] = tabIndex;
3477 window.sessionStorage.setItem('tab', JSON.stringify(state));
3479 catch (e) { return false; }
3485 updateTabs: function(ev, root) {
3486 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3487 var menu = pane.parentNode.previousElementSibling,
3488 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3489 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3494 if (this.isEmptyPane(pane)) {
3495 tab.style.display = 'none';
3496 tab.classList.remove('flash');
3498 else if (tab.style.display === 'none') {
3499 tab.style.display = '';
3500 requestAnimationFrame(function() { tab.classList.add('flash') });
3504 tab.setAttribute('data-errors', n_errors);
3505 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3506 tab.setAttribute('data-tooltip-style', 'error');
3509 tab.removeAttribute('data-errors');
3510 tab.removeAttribute('data-tooltip');
3516 switchTab: function(ev) {
3517 var tab = ev.target.parentNode,
3518 name = tab.getAttribute('data-tab'),
3519 menu = tab.parentNode,
3520 group = menu.nextElementSibling,
3521 groupId = +group.getAttribute('data-tab-group'),
3524 ev.preventDefault();
3526 if (!tab.classList.contains('cbi-tab-disabled'))
3529 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3530 tab.classList.remove('cbi-tab');
3531 tab.classList.remove('cbi-tab-disabled');
3533 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3536 group.childNodes.forEach(function(pane) {
3537 if (dom.matches(pane, '[data-tab]')) {
3538 if (pane.getAttribute('data-tab') === name) {
3539 pane.setAttribute('data-tab-active', 'true');
3540 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3541 UI.prototype.tabs.setActiveTabId(pane, index);
3544 pane.setAttribute('data-tab-active', 'false');
3554 * @typedef {Object} FileUploadReply
3557 * @property {string} name - Name of the uploaded file without directory components
3558 * @property {number} size - Size of the uploaded file in bytes
3559 * @property {string} checksum - The MD5 checksum of the received file data
3560 * @property {string} sha256sum - The SHA256 checksum of the received file data
3564 * Display a modal file upload prompt.
3566 * This function opens a modal dialog prompting the user to select and
3567 * upload a file to a predefined remote destination path.
3569 * @param {string} path
3570 * The remote file path to upload the local file to.
3572 * @param {Node} [progessStatusNode]
3573 * An optional DOM text node whose content text is set to the progress
3574 * percentage value during file upload.
3576 * @returns {Promise<LuCI.ui.FileUploadReply>}
3577 * Returns a promise resolving to a file upload status object on success
3578 * or rejecting with an error in case the upload failed or has been
3579 * cancelled by the user.
3581 uploadFile: function(path, progressStatusNode) {
3582 return new Promise(function(resolveFn, rejectFn) {
3583 UI.prototype.showModal(_('Uploading file…'), [
3584 E('p', _('Please select the file to upload.')),
3585 E('div', { 'style': 'display:flex' }, [
3586 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3589 style: 'display:none',
3590 change: function(ev) {
3591 var modal = dom.parent(ev.target, '.modal'),
3592 body = modal.querySelector('p'),
3593 upload = modal.querySelector('.cbi-button-action.important'),
3594 file = ev.currentTarget.files[0];
3601 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3602 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3606 upload.disabled = false;
3612 'click': function(ev) {
3613 ev.target.previousElementSibling.click();
3615 }, [ _('Browse…') ])
3617 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3620 'click': function() {
3621 UI.prototype.hideModal();
3622 rejectFn(new Error('Upload has been cancelled'));
3624 }, [ _('Cancel') ]),
3627 'class': 'btn cbi-button-action important',
3629 'click': function(ev) {
3630 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3632 if (!input.files[0])
3635 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3637 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3639 var data = new FormData();
3641 data.append('sessionid', rpc.getSessionID());
3642 data.append('filename', path);
3643 data.append('filedata', input.files[0]);
3645 var filename = input.files[0].name;
3647 request.post(L.env.cgi_base + '/cgi-upload', data, {
3649 progress: function(pev) {
3650 var percent = (pev.loaded / pev.total) * 100;
3652 if (progressStatusNode)
3653 progressStatusNode.data = '%.2f%%'.format(percent);
3655 progress.setAttribute('title', '%.2f%%'.format(percent));
3656 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3658 }).then(function(res) {
3659 var reply = res.json();
3661 UI.prototype.hideModal();
3663 if (L.isObject(reply) && reply.failure) {
3664 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3665 rejectFn(new Error(reply.failure));
3668 reply.name = filename;
3672 UI.prototype.hideModal();
3684 * Perform a device connectivity test.
3686 * Attempt to fetch a well known ressource from the remote device via HTTP
3687 * in order to test connectivity. This function is mainly useful to wait
3688 * for the router to come back online after a reboot or reconfiguration.
3690 * @param {string} [proto=http]
3691 * The protocol to use for fetching the resource. May be either `http`
3692 * (the default) or `https`.
3694 * @param {string} [host=window.location.host]
3695 * Override the host address to probe. By default the current host as seen
3696 * in the address bar is probed.
3698 * @returns {Promise<Event>}
3699 * Returns a promise resolving to a `load` event in case the device is
3700 * reachable or rejecting with an `error` event in case it is not reachable
3701 * or rejecting with `null` when the connectivity check timed out.
3703 pingDevice: function(proto, ipaddr) {
3704 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3706 return new Promise(function(resolveFn, rejectFn) {
3707 var img = new Image();
3709 img.onload = resolveFn;
3710 img.onerror = rejectFn;
3712 window.setTimeout(rejectFn, 1000);
3719 * Wait for device to come back online and reconnect to it.
3721 * Poll each given hostname or IP address and navigate to it as soon as
3722 * one of the addresses becomes reachable.
3724 * @param {...string} [hosts=[window.location.host]]
3725 * The list of IP addresses and host names to check for reachability.
3726 * If omitted, the current value of `window.location.host` is used by
3729 awaitReconnect: function(/* ... */) {
3730 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3732 window.setTimeout(L.bind(function() {
3733 poll.add(L.bind(function() {
3734 var tasks = [], reachable = false;
3736 for (var i = 0; i < 2; i++)
3737 for (var j = 0; j < ipaddrs.length; j++)
3738 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3739 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3741 return Promise.all(tasks).then(function() {
3744 window.location = reachable;
3757 * The `changes` class encapsulates logic for visualizing, applying,
3758 * confirming and reverting staged UCI changesets.
3760 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3761 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3762 * external JavaScript, use `L.require("ui").then(...)` and access the
3763 * `changes` property of the class instance value.
3765 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3767 if (!L.env.sessionid)
3770 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3774 * Set the change count indicator.
3776 * This function updates or hides the UCI change count indicator,
3777 * depending on the passed change count. When the count is greater
3778 * than 0, the change indicator is displayed or updated, otherwise it
3782 * @memberof LuCI.ui.changes
3783 * @param {number} numChanges
3784 * The number of changes to indicate.
3786 setIndicator: function(n) {
3787 var i = document.querySelector('.uci_change_indicator');
3789 var poll = document.getElementById('xhr_poll_status');
3790 i = poll.parentNode.insertBefore(E('a', {
3792 'class': 'uci_change_indicator label notice',
3793 'click': L.bind(this.displayChanges, this)
3798 dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
3799 i.classList.add('flash');
3800 i.style.display = '';
3801 document.dispatchEvent(new CustomEvent('uci-new-changes'));
3804 i.classList.remove('flash');
3805 i.style.display = 'none';
3806 document.dispatchEvent(new CustomEvent('uci-clear-changes'));
3811 * Update the change count indicator.
3813 * This function updates the UCI change count indicator from the given
3814 * UCI changeset structure.
3817 * @memberof LuCI.ui.changes
3818 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3819 * The UCI changeset to count.
3821 renderChangeIndicator: function(changes) {
3824 for (var config in changes)
3825 if (changes.hasOwnProperty(config))
3826 n_changes += changes[config].length;
3828 this.changes = changes;
3829 this.setIndicator(n_changes);
3834 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3835 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3836 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3837 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
3838 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3839 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3840 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3841 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3842 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
3843 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3847 * Display the current changelog.
3849 * Open a modal dialog visualizing the currently staged UCI changes
3850 * and offer options to revert or apply the shown changes.
3853 * @memberof LuCI.ui.changes
3855 displayChanges: function() {
3856 var list = E('div', { 'class': 'uci-change-list' }),
3857 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3858 E('div', { 'class': 'cbi-section' }, [
3859 E('strong', _('Legend:')),
3860 E('div', { 'class': 'uci-change-legend' }, [
3861 E('div', { 'class': 'uci-change-legend-label' }, [
3862 E('ins', ' '), ' ', _('Section added') ]),
3863 E('div', { 'class': 'uci-change-legend-label' }, [
3864 E('del', ' '), ' ', _('Section removed') ]),
3865 E('div', { 'class': 'uci-change-legend-label' }, [
3866 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
3867 E('div', { 'class': 'uci-change-legend-label' }, [
3868 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
3870 E('div', { 'class': 'right' }, [
3873 'click': UI.prototype.hideModal
3874 }, [ _('Dismiss') ]), ' ',
3876 'class': 'cbi-button cbi-button-positive important',
3877 'click': L.bind(this.apply, this, true)
3878 }, [ _('Save & Apply') ]), ' ',
3880 'class': 'cbi-button cbi-button-reset',
3881 'click': L.bind(this.revert, this)
3882 }, [ _('Revert') ])])])
3885 for (var config in this.changes) {
3886 if (!this.changes.hasOwnProperty(config))
3889 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
3891 for (var i = 0, added = null; i < this.changes[config].length; i++) {
3892 var chg = this.changes[config][i],
3893 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
3895 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
3901 if (added != null && chg[1] == added[0])
3902 return '@' + added[1] + '[-1]';
3907 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
3914 if (chg[0] == 'add')
3915 added = [ chg[1], chg[2] ];
3919 list.appendChild(E('br'));
3920 dlg.classList.add('uci-dialog');
3924 displayStatus: function(type, content) {
3926 var message = UI.prototype.showModal('', '');
3928 message.classList.add('alert-message');
3929 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
3932 dom.content(message, content);
3934 if (!this.was_polling) {
3935 this.was_polling = request.poll.active();
3936 request.poll.stop();
3940 UI.prototype.hideModal();
3942 if (this.was_polling)
3943 request.poll.start();
3948 rollback: function(checked) {
3950 this.displayStatus('warning spinning',
3951 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
3952 .format(L.env.apply_rollback)));
3954 var call = function(r, data, duration) {
3955 if (r.status === 204) {
3956 UI.prototype.changes.displayStatus('warning', [
3957 E('h4', _('Configuration changes have been rolled back!')),
3958 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)),
3959 E('div', { 'class': 'right' }, [
3962 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
3963 }, [ _('Dismiss') ]), ' ',
3965 'class': 'btn cbi-button-action important',
3966 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
3967 }, [ _('Revert changes') ]), ' ',
3969 'class': 'btn cbi-button-negative important',
3970 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
3971 }, [ _('Apply unchecked') ])
3978 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
3979 window.setTimeout(function() {
3980 request.request(L.url('admin/uci/confirm'), {
3982 timeout: L.env.apply_timeout * 1000,
3983 query: { sid: L.env.sessionid, token: L.env.token }
3988 call({ status: 0 });
3991 this.displayStatus('warning', [
3992 E('h4', _('Device unreachable!')),
3993 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.'))
3999 confirm: function(checked, deadline, override_token) {
4001 var ts = Date.now();
4003 this.displayStatus('notice');
4006 this.confirm_auth = { token: override_token };
4008 var call = function(r, data, duration) {
4009 if (Date.now() >= deadline) {
4010 window.clearTimeout(tt);
4011 UI.prototype.changes.rollback(checked);
4014 else if (r && (r.status === 200 || r.status === 204)) {
4015 document.dispatchEvent(new CustomEvent('uci-applied'));
4017 UI.prototype.changes.setIndicator(0);
4018 UI.prototype.changes.displayStatus('notice',
4019 E('p', _('Configuration changes applied.')));
4021 window.clearTimeout(tt);
4022 window.setTimeout(function() {
4023 //UI.prototype.changes.displayStatus(false);
4024 window.location = window.location.href.split('#')[0];
4025 }, L.env.apply_display * 1000);
4030 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4031 window.setTimeout(function() {
4032 request.request(L.url('admin/uci/confirm'), {
4034 timeout: L.env.apply_timeout * 1000,
4035 query: UI.prototype.changes.confirm_auth
4036 }).then(call, call);
4040 var tick = function() {
4041 var now = Date.now();
4043 UI.prototype.changes.displayStatus('notice spinning',
4044 E('p', _('Applying configuration changes… %ds')
4045 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4047 if (now >= deadline)
4050 tt = window.setTimeout(tick, 1000 - (now - ts));
4056 /* wait a few seconds for the settings to become effective */
4057 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4061 * Apply the staged configuration changes.
4063 * Start applying staged configuration changes and open a modal dialog
4064 * with a progress indication to prevent interaction with the view
4065 * during the apply process. The modal dialog will be automatically
4066 * closed and the current view reloaded once the apply process is
4070 * @memberof LuCI.ui.changes
4071 * @param {boolean} [checked=false]
4072 * Whether to perform a checked (`true`) configuration apply or an
4073 * unchecked (`false`) one.
4075 * In case of a checked apply, the configuration changes must be
4076 * confirmed within a specific time interval, otherwise the device
4077 * will begin to roll back the changes in order to restore the previous
4080 apply: function(checked) {
4081 this.displayStatus('notice spinning',
4082 E('p', _('Starting configuration apply…')));
4084 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4086 query: { sid: L.env.sessionid, token: L.env.token }
4087 }).then(function(r) {
4088 if (r.status === (checked ? 200 : 204)) {
4089 var tok = null; try { tok = r.json(); } catch(e) {}
4090 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4091 UI.prototype.changes.confirm_auth = tok;
4093 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4095 else if (checked && r.status === 204) {
4096 UI.prototype.changes.displayStatus('notice',
4097 E('p', _('There are no changes to apply')));
4099 window.setTimeout(function() {
4100 UI.prototype.changes.displayStatus(false);
4101 }, L.env.apply_display * 1000);
4104 UI.prototype.changes.displayStatus('warning',
4105 E('p', _('Apply request failed with status <code>%h</code>')
4106 .format(r.responseText || r.statusText || r.status)));
4108 window.setTimeout(function() {
4109 UI.prototype.changes.displayStatus(false);
4110 }, L.env.apply_display * 1000);
4116 * Revert the staged configuration changes.
4118 * Start reverting staged configuration changes and open a modal dialog
4119 * with a progress indication to prevent interaction with the view
4120 * during the revert process. The modal dialog will be automatically
4121 * closed and the current view reloaded once the revert process is
4125 * @memberof LuCI.ui.changes
4127 revert: function() {
4128 this.displayStatus('notice spinning',
4129 E('p', _('Reverting configuration…')));
4131 request.request(L.url('admin/uci/revert'), {
4133 query: { sid: L.env.sessionid, token: L.env.token }
4134 }).then(function(r) {
4135 if (r.status === 200) {
4136 document.dispatchEvent(new CustomEvent('uci-reverted'));
4138 UI.prototype.changes.setIndicator(0);
4139 UI.prototype.changes.displayStatus('notice',
4140 E('p', _('Changes have been reverted.')));
4142 window.setTimeout(function() {
4143 //UI.prototype.changes.displayStatus(false);
4144 window.location = window.location.href.split('#')[0];
4145 }, L.env.apply_display * 1000);
4148 UI.prototype.changes.displayStatus('warning',
4149 E('p', _('Revert request failed with status <code>%h</code>')
4150 .format(r.statusText || r.status)));
4152 window.setTimeout(function() {
4153 UI.prototype.changes.displayStatus(false);
4154 }, L.env.apply_display * 1000);
4161 * Add validation constraints to an input element.
4163 * Compile the given type expression and optional validator function into
4164 * a validation function and bind it to the specified input element events.
4166 * @param {Node} field
4167 * The DOM input element node to bind the validation constraints to.
4169 * @param {string} type
4170 * The datatype specification to describe validation constraints.
4171 * Refer to the `LuCI.validation` class documentation for details.
4173 * @param {boolean} [optional=false]
4174 * Specifies whether empty values are allowed (`true`) or not (`false`).
4175 * If an input element is not marked optional it must not be empty,
4176 * otherwise it will be marked as invalid.
4178 * @param {function} [vfunc]
4179 * Specifies a custom validation function which is invoked after the
4180 * other validation constraints are applied. The validation must return
4181 * `true` to accept the passed value. Any other return type is converted
4182 * to a string and treated as validation error message.
4184 * @param {...string} [events=blur, keyup]
4185 * The list of events to bind. Each received event will trigger a field
4186 * validation. If omitted, the `keyup` and `blur` events are bound by
4189 * @returns {function}
4190 * Returns the compiled validator function which can be used to manually
4191 * trigger field validation or to bind it to further events.
4193 * @see LuCI.validation
4195 addValidator: function(field, type, optional, vfunc /*, ... */) {
4199 var events = this.varargs(arguments, 3);
4200 if (events.length == 0)
4201 events.push('blur', 'keyup');
4204 var cbiValidator = validation.create(field, type, optional, vfunc),
4205 validatorFn = cbiValidator.validate.bind(cbiValidator);
4207 for (var i = 0; i < events.length; i++)
4208 field.addEventListener(events[i], validatorFn);
4218 * Create a pre-bound event handler function.
4220 * Generate and bind a function suitable for use in event handlers. The
4221 * generated function automatically disables the event source element
4222 * and adds an active indication to it by adding appropriate CSS classes.
4224 * It will also await any promises returned by the wrapped function and
4225 * re-enable the source element after the promises ran to completion.
4228 * The `this` context to use for the wrapped function.
4230 * @param {function|string} fn
4231 * Specifies the function to wrap. In case of a function value, the
4232 * function is used as-is. If a string is specified instead, it is looked
4233 * up in `ctx` to obtain the function to wrap. In both cases the bound
4234 * function will be invoked with `ctx` as `this` context
4236 * @param {...*} extra_args
4237 * Any further parameter as passed as-is to the bound event handler
4238 * function in the same order as passed to `createHandlerFn()`.
4240 * @returns {function|null}
4241 * Returns the pre-bound handler function which is suitable to be passed
4242 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4243 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4244 * valid function value.
4246 createHandlerFn: function(ctx, fn /*, ... */) {
4247 if (typeof(fn) == 'string')
4250 if (typeof(fn) != 'function')
4253 var arg_offset = arguments.length - 2;
4255 return Function.prototype.bind.apply(function() {
4256 var t = arguments[arg_offset].currentTarget;
4258 t.classList.add('spinning');
4264 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4265 t.classList.remove('spinning');
4268 }, this.varargs(arguments, 2, ctx));
4272 * Load specified view class path and set it up.
4274 * Transforms the given view path into a class name, requires it
4275 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4276 * resulting class instance is a descendant of
4277 * [LuCI.view]{@link LuCI.view}.
4279 * By instantiating the view class, its corresponding contents are
4280 * rendered and included into the view area. Any runtime errors are
4281 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4283 * @param {string} path
4284 * The view path to render.
4286 * @returns {Promise<LuCI.view>}
4287 * Returns a promise resolving to the loaded view instance.
4289 instantiateView: function(path) {
4290 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4292 return L.require(className).then(function(view) {
4293 if (!(view instanceof View))
4294 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4297 }).catch(function(err) {
4298 dom.content(document.querySelector('#view'), null);
4303 AbstractElement: UIElement,
4306 Textfield: UITextfield,
4307 Textarea: UITextarea,
4308 Checkbox: UICheckbox,
4310 Dropdown: UIDropdown,
4311 DynamicList: UIDynamicList,
4312 Combobox: UICombobox,
4313 ComboButton: UIComboButton,
4314 Hiddenfield: UIHiddenfield,
4315 FileUpload: UIFileUpload