15 tooltipTimeout = null;
18 * @class AbstractElement
23 * The `AbstractElement` class serves as abstract base for the different widgets
24 * implemented by `LuCI.ui`. It provides the common logic for getting and
25 * setting values, for checking the validity state and for wiring up required
28 * UI widget instances are usually not supposed to be created by view code
29 * directly, instead they're implicitely created by `LuCI.form` when
30 * instantiating CBI forms.
32 * This class is automatically instantiated as part of `LuCI.ui`. To use it
33 * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
34 * it in external JavaScript, use `L.require("ui").then(...)` and access the
35 * `AbstractElement` property of the class instance value.
37 var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
39 * @typedef {Object} InitOptions
40 * @memberof LuCI.ui.AbstractElement
42 * @property {string} [id]
43 * Specifies the widget ID to use. It will be used as HTML `id` attribute
44 * on the toplevel widget DOM node.
46 * @property {string} [name]
47 * Specifies the widget name which is set as HTML `name` attribute on the
48 * corresponding `<input>` element.
50 * @property {boolean} [optional=true]
51 * Specifies whether the input field allows empty values.
53 * @property {string} [datatype=string]
54 * An expression describing the input data validation constraints.
55 * It defaults to `string` which will allow any value.
56 * See {@link LuCI.validation} for details on the expression format.
58 * @property {function} [validator]
59 * Specifies a custom validator function which is invoked after the
60 * standard validation constraints are checked. The function should return
61 * `true` to accept the given input value. Any other return value type is
62 * converted to a string and treated as validation error message.
64 * @property {boolean} [disabled=false]
65 * Specifies whether the widget should be rendered in disabled state
66 * (`true`) or not (`false`). Disabled widgets cannot be interacted with
67 * and are displayed in a slightly faded style.
71 * Read the current value of the input widget.
74 * @memberof LuCI.ui.AbstractElement
75 * @returns {string|string[]|null}
76 * The current value of the input element. For simple inputs like text
77 * fields or selects, the return value type will be a - possibly empty -
78 * string. Complex widgets such as `DynamicList` instances may result in
79 * an array of strings or `null` for unset values.
81 getValue: function() {
82 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
83 return this.node.value;
89 * Set the current value of the input widget.
92 * @memberof LuCI.ui.AbstractElement
93 * @param {string|string[]|null} value
94 * The value to set the input element to. For simple inputs like text
95 * fields or selects, the value should be a - possibly empty - string.
96 * Complex widgets such as `DynamicList` instances may accept string array
99 setValue: function(value) {
100 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
101 this.node.value = value;
105 * Check whether the current input value is valid.
108 * @memberof LuCI.ui.AbstractElement
110 * Returns `true` if the current input value is valid or `false` if it does
111 * not meet the validation constraints.
113 isValid: function() {
114 return (this.validState !== false);
118 * Force validation of the current input value.
120 * Usually input validation is automatically triggered by various DOM events
121 * bound to the input widget. In some cases it is required though to manually
122 * trigger validation runs, e.g. when programmatically altering values.
125 * @memberof LuCI.ui.AbstractElement
127 triggerValidation: function() {
128 if (typeof(this.vfunc) != 'function')
131 var wasValid = this.isValid();
135 return (wasValid != this.isValid());
139 * Dispatch a custom (synthetic) event in response to received events.
141 * Sets up event handlers on the given target DOM node for the given event
142 * names that dispatch a custom event of the given type to the widget root
145 * The primary purpose of this function is to set up a series of custom
146 * uniform standard events such as `widget-update`, `validation-success`,
147 * `validation-failure` etc. which are triggered by various different
148 * widget specific native DOM events.
151 * @memberof LuCI.ui.AbstractElement
152 * @param {Node} targetNode
153 * Specifies the DOM node on which the native event listeners should be
156 * @param {string} synevent
157 * The name of the custom event to dispatch to the widget root DOM node.
159 * @param {string[]} events
160 * The native DOM events for which event handlers should be registered.
162 registerEvents: function(targetNode, synevent, events) {
163 var dispatchFn = L.bind(function(ev) {
164 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
167 for (var i = 0; i < events.length; i++)
168 targetNode.addEventListener(events[i], dispatchFn);
172 * Setup listeners for native DOM events that may update the widget value.
174 * Sets up event handlers on the given target DOM node for the given event
175 * names which may cause the input value to update, such as `keyup` or
176 * `onclick` events. In contrast to change events, such update events will
177 * trigger input value validation.
180 * @memberof LuCI.ui.AbstractElement
181 * @param {Node} targetNode
182 * Specifies the DOM node on which the event listeners should be registered.
184 * @param {...string} events
185 * The DOM events for which event handlers should be registered.
187 setUpdateEvents: function(targetNode /*, ... */) {
188 var datatype = this.options.datatype,
189 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
190 validate = this.options.validate,
191 events = this.varargs(arguments, 1);
193 this.registerEvents(targetNode, 'widget-update', events);
195 if (!datatype && !validate)
198 this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
199 targetNode, datatype || 'string',
203 this.node.addEventListener('validation-success', L.bind(function(ev) {
204 this.validState = true;
207 this.node.addEventListener('validation-failure', L.bind(function(ev) {
208 this.validState = false;
213 * Setup listeners for native DOM events that may change the widget value.
215 * Sets up event handlers on the given target DOM node for the given event
216 * names which may cause the input value to change completely, such as
217 * `change` events in a select menu. In contrast to update events, such
218 * change events will not trigger input value validation but they may cause
219 * field dependencies to get re-evaluated and will mark the input widget
223 * @memberof LuCI.ui.AbstractElement
224 * @param {Node} targetNode
225 * Specifies the DOM node on which the event listeners should be registered.
227 * @param {...string} events
228 * The DOM events for which event handlers should be registered.
230 setChangeEvents: function(targetNode /*, ... */) {
231 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
233 for (var i = 1; i < arguments.length; i++)
234 targetNode.addEventListener(arguments[i], tag_changed);
236 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
240 * Render the widget, setup event listeners and return resulting markup.
243 * @memberof LuCI.ui.AbstractElement
246 * Returns a DOM Node or DocumentFragment containing the rendered
249 render: function() {}
253 * Instantiate a text input widget.
255 * @constructor Textfield
257 * @augments LuCI.ui.AbstractElement
261 * The `Textfield` class implements a standard single line text input field.
263 * UI widget instances are usually not supposed to be created by view code
264 * directly, instead they're implicitely created by `LuCI.form` when
265 * instantiating CBI forms.
267 * This class is automatically instantiated as part of `LuCI.ui`. To use it
268 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
269 * external JavaScript, use `L.require("ui").then(...)` and access the
270 * `Textfield` property of the class instance value.
272 * @param {string} [value=null]
273 * The initial input value.
275 * @param {LuCI.ui.Textfield.InitOptions} [options]
276 * Object describing the widget specific options to initialize the input.
278 var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
280 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
281 * the following properties are recognized:
283 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
284 * @memberof LuCI.ui.Textfield
286 * @property {boolean} [password=false]
287 * Specifies whether the input should be rendered as concealed password field.
289 * @property {boolean} [readonly=false]
290 * Specifies whether the input widget should be rendered readonly.
292 * @property {number} [maxlength]
293 * Specifies the HTML `maxlength` attribute to set on the corresponding
294 * `<input>` element. Note that this a legacy property that exists for
295 * compatibility reasons. It is usually better to `maxlength(N)` validation
298 * @property {string} [placeholder]
299 * Specifies the HTML `placeholder` attribute which is displayed when the
300 * corresponding `<input>` element is empty.
302 __init__: function(value, options) {
304 this.options = Object.assign({
312 var frameEl = E('div', { 'id': this.options.id });
314 if (this.options.password) {
315 frameEl.classList.add('nowrap');
316 frameEl.appendChild(E('input', {
318 'style': 'position:absolute; left:-100000px',
321 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
325 frameEl.appendChild(E('input', {
326 'id': this.options.id ? 'widget.' + this.options.id : null,
327 'name': this.options.name,
328 'type': this.options.password ? 'password' : 'text',
329 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
330 'readonly': this.options.readonly ? '' : null,
331 'disabled': this.options.disabled ? '' : null,
332 'maxlength': this.options.maxlength,
333 'placeholder': this.options.placeholder,
337 if (this.options.password)
338 frameEl.appendChild(E('button', {
339 'class': 'cbi-button cbi-button-neutral',
340 'title': _('Reveal/hide password'),
341 'aria-label': _('Reveal/hide password'),
342 'click': function(ev) {
343 var e = this.previousElementSibling;
344 e.type = (e.type === 'password') ? 'text' : 'password';
349 return this.bind(frameEl);
353 bind: function(frameEl) {
354 var inputEl = frameEl.childNodes[+!!this.options.password];
358 this.setUpdateEvents(inputEl, 'keyup', 'blur');
359 this.setChangeEvents(inputEl, 'change');
361 dom.bindClassInstance(frameEl, this);
367 getValue: function() {
368 var inputEl = this.node.childNodes[+!!this.options.password];
369 return inputEl.value;
373 setValue: function(value) {
374 var inputEl = this.node.childNodes[+!!this.options.password];
375 inputEl.value = value;
380 * Instantiate a textarea widget.
382 * @constructor Textarea
384 * @augments LuCI.ui.AbstractElement
388 * The `Textarea` class implements a multiline text area input field.
390 * UI widget instances are usually not supposed to be created by view code
391 * directly, instead they're implicitely created by `LuCI.form` when
392 * instantiating CBI forms.
394 * This class is automatically instantiated as part of `LuCI.ui`. To use it
395 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
396 * external JavaScript, use `L.require("ui").then(...)` and access the
397 * `Textarea` property of the class instance value.
399 * @param {string} [value=null]
400 * The initial input value.
402 * @param {LuCI.ui.Textarea.InitOptions} [options]
403 * Object describing the widget specific options to initialize the input.
405 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
407 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
408 * the following properties are recognized:
410 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
411 * @memberof LuCI.ui.Textarea
413 * @property {boolean} [readonly=false]
414 * Specifies whether the input widget should be rendered readonly.
416 * @property {string} [placeholder]
417 * Specifies the HTML `placeholder` attribute which is displayed when the
418 * corresponding `<textarea>` element is empty.
420 * @property {boolean} [monospace=false]
421 * Specifies whether a monospace font should be forced for the textarea
424 * @property {number} [cols]
425 * Specifies the HTML `cols` attribute to set on the corresponding
426 * `<textarea>` element.
428 * @property {number} [rows]
429 * Specifies the HTML `rows` attribute to set on the corresponding
430 * `<textarea>` element.
432 * @property {boolean} [wrap=false]
433 * Specifies whether the HTML `wrap` attribute should be set.
435 __init__: function(value, options) {
437 this.options = Object.assign({
447 var style = !this.options.cols ? 'width:100%' : null,
448 frameEl = E('div', { 'id': this.options.id, 'style': style }),
449 value = (this.value != null) ? String(this.value) : '';
451 frameEl.appendChild(E('textarea', {
452 'id': this.options.id ? 'widget.' + this.options.id : null,
453 'name': this.options.name,
454 'class': 'cbi-input-textarea',
455 'readonly': this.options.readonly ? '' : null,
456 'disabled': this.options.disabled ? '' : null,
457 'placeholder': this.options.placeholder,
459 'cols': this.options.cols,
460 'rows': this.options.rows,
461 'wrap': this.options.wrap ? '' : null
464 if (this.options.monospace)
465 frameEl.firstElementChild.style.fontFamily = 'monospace';
467 return this.bind(frameEl);
471 bind: function(frameEl) {
472 var inputEl = frameEl.firstElementChild;
476 this.setUpdateEvents(inputEl, 'keyup', 'blur');
477 this.setChangeEvents(inputEl, 'change');
479 dom.bindClassInstance(frameEl, this);
485 getValue: function() {
486 return this.node.firstElementChild.value;
490 setValue: function(value) {
491 this.node.firstElementChild.value = value;
496 * Instantiate a checkbox widget.
498 * @constructor Checkbox
500 * @augments LuCI.ui.AbstractElement
504 * The `Checkbox` class implements a simple checkbox input field.
506 * UI widget instances are usually not supposed to be created by view code
507 * directly, instead they're implicitely created by `LuCI.form` when
508 * instantiating CBI forms.
510 * This class is automatically instantiated as part of `LuCI.ui`. To use it
511 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
512 * external JavaScript, use `L.require("ui").then(...)` and access the
513 * `Checkbox` property of the class instance value.
515 * @param {string} [value=null]
516 * The initial input value.
518 * @param {LuCI.ui.Checkbox.InitOptions} [options]
519 * Object describing the widget specific options to initialize the input.
521 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
523 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
524 * the following properties are recognized:
526 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
527 * @memberof LuCI.ui.Checkbox
529 * @property {string} [value_enabled=1]
530 * Specifies the value corresponding to a checked checkbox.
532 * @property {string} [value_disabled=0]
533 * Specifies the value corresponding to an unchecked checkbox.
535 * @property {string} [hiddenname]
536 * Specifies the HTML `name` attribute of the hidden input backing the
537 * checkbox. This is a legacy property existing for compatibility reasons,
538 * it is required for HTML based form submissions.
540 __init__: function(value, options) {
542 this.options = Object.assign({
550 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
551 var frameEl = E('div', {
552 'id': this.options.id,
553 'class': 'cbi-checkbox'
556 if (this.options.hiddenname)
557 frameEl.appendChild(E('input', {
559 'name': this.options.hiddenname,
563 frameEl.appendChild(E('input', {
565 'name': this.options.name,
567 'value': this.options.value_enabled,
568 'checked': (this.value == this.options.value_enabled) ? '' : null,
569 'disabled': this.options.disabled ? '' : null,
570 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
573 frameEl.appendChild(E('label', { 'for': id }));
575 return this.bind(frameEl);
579 bind: function(frameEl) {
582 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
583 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
585 dom.bindClassInstance(frameEl, this);
591 * Test whether the checkbox is currently checked.
594 * @memberof LuCI.ui.Checkbox
596 * Returns `true` when the checkbox is currently checked, otherwise `false`.
598 isChecked: function() {
599 return this.node.lastElementChild.previousElementSibling.checked;
603 getValue: function() {
604 return this.isChecked()
605 ? this.options.value_enabled
606 : this.options.value_disabled;
610 setValue: function(value) {
611 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
616 * Instantiate a select dropdown or checkbox/radiobutton group.
618 * @constructor Select
620 * @augments LuCI.ui.AbstractElement
624 * The `Select` class implements either a traditional HTML `<select>` element
625 * or a group of checkboxes or radio buttons, depending on whether multiple
626 * values are enabled or not.
628 * UI widget instances are usually not supposed to be created by view code
629 * directly, instead they're implicitely created by `LuCI.form` when
630 * instantiating CBI forms.
632 * This class is automatically instantiated as part of `LuCI.ui`. To use it
633 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
634 * external JavaScript, use `L.require("ui").then(...)` and access the
635 * `Select` property of the class instance value.
637 * @param {string|string[]} [value=null]
638 * The initial input value(s).
640 * @param {Object<string, string>} choices
641 * Object containing the selectable choices of the widget. The object keys
642 * serve as values for the different choices while the values are used as
645 * @param {LuCI.ui.Select.InitOptions} [options]
646 * Object describing the widget specific options to initialize the inputs.
648 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
650 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
651 * the following properties are recognized:
653 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
654 * @memberof LuCI.ui.Select
656 * @property {boolean} [multiple=false]
657 * Specifies whether multiple choice values may be selected.
659 * @property {string} [widget=select]
660 * Specifies the kind of widget to render. May be either `select` or
661 * `individual`. When set to `select` an HTML `<select>` element will be
662 * used, otherwise a group of checkbox or radio button elements is created,
663 * depending on the value of the `multiple` option.
665 * @property {string} [orientation=horizontal]
666 * Specifies whether checkbox / radio button groups should be rendered
667 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
670 * @property {boolean|string[]} [sort=false]
671 * Specifies if and how to sort choice values. If set to `true`, the choice
672 * values will be sorted alphabetically. If set to an array of strings, the
673 * choice sort order is derived from the array.
675 * @property {number} [size]
676 * Specifies the HTML `size` attribute to set on the `<select>` element.
677 * Only applicable to the `select` widget type.
679 * @property {string} [placeholder=-- Please choose --]
680 * Specifies a placeholder text which is displayed when no choice is
681 * selected yet. Only applicable to the `select` widget type.
683 __init__: function(value, choices, options) {
684 if (!L.isObject(choices))
687 if (!Array.isArray(value))
688 value = (value != null && value != '') ? [ value ] : [];
690 if (!options.multiple && value.length > 1)
694 this.choices = choices;
695 this.options = Object.assign({
698 orientation: 'horizontal'
701 if (this.choices.hasOwnProperty(''))
702 this.options.optional = true;
707 var frameEl = E('div', { 'id': this.options.id }),
708 keys = Object.keys(this.choices);
710 if (this.options.sort === true)
712 else if (Array.isArray(this.options.sort))
713 keys = this.options.sort;
715 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
716 frameEl.appendChild(E('select', {
717 'id': this.options.id ? 'widget.' + this.options.id : null,
718 'name': this.options.name,
719 'size': this.options.size,
720 'class': 'cbi-input-select',
721 'multiple': this.options.multiple ? '' : null,
722 'disabled': this.options.disabled ? '' : null
725 if (this.options.optional)
726 frameEl.lastChild.appendChild(E('option', {
728 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
729 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
731 for (var i = 0; i < keys.length; i++) {
732 if (keys[i] == null || keys[i] == '')
735 frameEl.lastChild.appendChild(E('option', {
737 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
738 }, [ this.choices[keys[i]] || keys[i] ]));
742 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' \xa0 ') : E('br');
744 for (var i = 0; i < keys.length; i++) {
745 frameEl.appendChild(E('span', {
746 'class': 'cbi-%s'.format(this.options.multiple ? 'checkbox' : 'radio')
749 'id': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null,
750 'name': this.options.id || this.options.name,
751 'type': this.options.multiple ? 'checkbox' : 'radio',
752 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
754 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
755 'disabled': this.options.disabled ? '' : null
757 E('label', { 'for': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null }),
759 'click': function(ev) {
760 ev.currentTarget.previousElementSibling.previousElementSibling.click();
762 }, [ this.choices[keys[i]] || keys[i] ])
765 frameEl.appendChild(brEl.cloneNode());
769 return this.bind(frameEl);
773 bind: function(frameEl) {
776 if (this.options.widget == 'select') {
777 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
778 this.setChangeEvents(frameEl.firstChild, 'change');
781 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
782 for (var i = 0; i < radioEls.length; i++) {
783 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
784 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
788 dom.bindClassInstance(frameEl, this);
794 getValue: function() {
795 if (this.options.widget == 'select')
796 return this.node.firstChild.value;
798 var radioEls = this.node.querySelectorAll('input[type="radio"]');
799 for (var i = 0; i < radioEls.length; i++)
800 if (radioEls[i].checked)
801 return radioEls[i].value;
807 setValue: function(value) {
808 if (this.options.widget == 'select') {
812 for (var i = 0; i < this.node.firstChild.options.length; i++)
813 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
818 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
819 for (var i = 0; i < radioEls.length; i++)
820 radioEls[i].checked = (radioEls[i].value == value);
825 * Instantiate a rich dropdown choice widget.
827 * @constructor Dropdown
829 * @augments LuCI.ui.AbstractElement
833 * The `Dropdown` class implements a rich, stylable dropdown menu which
834 * supports non-text choice labels.
836 * UI widget instances are usually not supposed to be created by view code
837 * directly, instead they're implicitely created by `LuCI.form` when
838 * instantiating CBI forms.
840 * This class is automatically instantiated as part of `LuCI.ui`. To use it
841 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
842 * external JavaScript, use `L.require("ui").then(...)` and access the
843 * `Dropdown` property of the class instance value.
845 * @param {string|string[]} [value=null]
846 * The initial input value(s).
848 * @param {Object<string, *>} choices
849 * Object containing the selectable choices of the widget. The object keys
850 * serve as values for the different choices while the values are used as
853 * @param {LuCI.ui.Dropdown.InitOptions} [options]
854 * Object describing the widget specific options to initialize the dropdown.
856 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
858 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
859 * the following properties are recognized:
861 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
862 * @memberof LuCI.ui.Dropdown
864 * @property {boolean} [optional=true]
865 * Specifies whether the dropdown selection is optional. In contrast to
866 * other widgets, the `optional` constraint of dropdowns works differently;
867 * instead of marking the widget invalid on empty values when set to `false`,
868 * the user is not allowed to deselect all choices.
870 * For single value dropdowns that means that no empty "please select"
871 * choice is offered and for multi value dropdowns, the last selected choice
872 * may not be deselected without selecting another choice first.
874 * @property {boolean} [multiple]
875 * Specifies whether multiple choice values may be selected. It defaults
876 * to `true` when an array is passed as input value to the constructor.
878 * @property {boolean|string[]} [sort=false]
879 * Specifies if and how to sort choice values. If set to `true`, the choice
880 * values will be sorted alphabetically. If set to an array of strings, the
881 * choice sort order is derived from the array.
883 * @property {string} [select_placeholder=-- Please choose --]
884 * Specifies a placeholder text which is displayed when no choice is
887 * @property {string} [custom_placeholder=-- custom --]
888 * Specifies a placeholder text which is displayed in the text input
889 * field allowing to enter custom choice values. Only applicable if the
890 * `create` option is set to `true`.
892 * @property {boolean} [create=false]
893 * Specifies whether custom choices may be entered into the dropdown
896 * @property {string} [create_query=.create-item-input]
897 * Specifies a CSS selector expression used to find the input element
898 * which is used to enter custom choice values. This should not normally
899 * be used except by widgets derived from the Dropdown class.
901 * @property {string} [create_template=script[type="item-template"]]
902 * Specifies a CSS selector expression used to find an HTML element
903 * serving as template for newly added custom choice values.
905 * Any `{{value}}` placeholder string within the template elements text
906 * content will be replaced by the user supplied choice value, the
907 * resulting string is parsed as HTML and appended to the end of the
908 * choice list. The template markup may specify one HTML element with a
909 * `data-label-placeholder` attribute which is replaced by a matching
910 * label value from the `choices` object or with the user supplied value
911 * itself in case `choices` contains no matching choice label.
913 * If the template element is not found or if no `create_template` selector
914 * expression is specified, the default markup for newly created elements is
915 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
917 * @property {string} [create_markup]
918 * This property allows specifying the markup for custom choices directly
919 * instead of referring to a template element through CSS selectors.
921 * Apart from that it works exactly like `create_template`.
923 * @property {number} [display_items=3]
924 * Specifies the maximum amount of choice labels that should be shown in
925 * collapsed dropdown state before further selected choices are cut off.
927 * Only applicable when `multiple` is `true`.
929 * @property {number} [dropdown_items=-1]
930 * Specifies the maximum amount of choices that should be shown when the
931 * dropdown is open. If the amount of available choices exceeds this number,
932 * the dropdown area must be scrolled to reach further items.
934 * If set to `-1`, the dropdown menu will attempt to show all choice values
935 * and only resort to scrolling if the amount of choices exceeds the available
936 * screen space above and below the dropdown widget.
938 * @property {string} [placeholder]
939 * This property serves as a shortcut to set both `select_placeholder` and
940 * `custom_placeholder`. Either of these properties will fallback to
941 * `placeholder` if not specified.
943 * @property {boolean} [readonly=false]
944 * Specifies whether the custom choice input field should be rendered
945 * readonly. Only applicable when `create` is `true`.
947 * @property {number} [maxlength]
948 * Specifies the HTML `maxlength` attribute to set on the custom choice
949 * `<input>` element. Note that this a legacy property that exists for
950 * compatibility reasons. It is usually better to `maxlength(N)` validation
951 * expression. Only applicable when `create` is `true`.
953 __init__: function(value, choices, options) {
954 if (typeof(choices) != 'object')
957 if (!Array.isArray(value))
958 this.values = (value != null && value != '') ? [ value ] : [];
962 this.choices = choices;
963 this.options = Object.assign({
965 multiple: Array.isArray(value),
967 select_placeholder: _('-- Please choose --'),
968 custom_placeholder: _('-- custom --'),
972 create_query: '.create-item-input',
973 create_template: 'script[type="item-template"]'
980 'id': this.options.id,
981 'class': 'cbi-dropdown',
982 'multiple': this.options.multiple ? '' : null,
983 'optional': this.options.optional ? '' : null,
984 'disabled': this.options.disabled ? '' : null
987 var keys = Object.keys(this.choices);
989 if (this.options.sort === true)
991 else if (Array.isArray(this.options.sort))
992 keys = this.options.sort;
994 if (this.options.create)
995 for (var i = 0; i < this.values.length; i++)
996 if (!this.choices.hasOwnProperty(this.values[i]))
997 keys.push(this.values[i]);
999 for (var i = 0; i < keys.length; i++) {
1000 var label = this.choices[keys[i]];
1002 if (dom.elem(label))
1003 label = label.cloneNode(true);
1005 sb.lastElementChild.appendChild(E('li', {
1006 'data-value': keys[i],
1007 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
1008 }, [ label || keys[i] ]));
1011 if (this.options.create) {
1012 var createEl = E('input', {
1014 'class': 'create-item-input',
1015 'readonly': this.options.readonly ? '' : null,
1016 'maxlength': this.options.maxlength,
1017 'placeholder': this.options.custom_placeholder || this.options.placeholder
1020 if (this.options.datatype || this.options.validate)
1021 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1022 true, this.options.validate, 'blur', 'keyup');
1024 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1027 if (this.options.create_markup)
1028 sb.appendChild(E('script', { type: 'item-template' },
1029 this.options.create_markup));
1031 return this.bind(sb);
1035 bind: function(sb) {
1036 var o = this.options;
1038 o.multiple = sb.hasAttribute('multiple');
1039 o.optional = sb.hasAttribute('optional');
1040 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1041 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1042 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1043 o.create_query = sb.getAttribute('item-create') || o.create_query;
1044 o.create_template = sb.getAttribute('item-template') || o.create_template;
1046 var ul = sb.querySelector('ul'),
1047 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1048 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, 'â–¾')),
1049 canary = sb.appendChild(E('div')),
1050 create = sb.querySelector(this.options.create_query),
1051 ndisplay = this.options.display_items,
1054 if (this.options.multiple) {
1055 var items = ul.querySelectorAll('li');
1057 for (var i = 0; i < items.length; i++) {
1058 this.transformItem(sb, items[i]);
1060 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1061 items[i].setAttribute('display', n++);
1065 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1066 var placeholder = E('li', { placeholder: '' },
1067 this.options.select_placeholder || this.options.placeholder);
1070 ? ul.insertBefore(placeholder, ul.firstChild)
1071 : ul.appendChild(placeholder);
1074 var items = ul.querySelectorAll('li'),
1075 sel = sb.querySelectorAll('[selected]');
1077 sel.forEach(function(s) {
1078 s.removeAttribute('selected');
1081 var s = sel[0] || items[0];
1083 s.setAttribute('selected', '');
1084 s.setAttribute('display', n++);
1090 this.saveValues(sb, ul);
1092 ul.setAttribute('tabindex', -1);
1093 sb.setAttribute('tabindex', 0);
1096 sb.setAttribute('more', '')
1098 sb.removeAttribute('more');
1100 if (ndisplay == this.options.display_items)
1101 sb.setAttribute('empty', '')
1103 sb.removeAttribute('empty');
1105 dom.content(more, (ndisplay == this.options.display_items)
1106 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1109 sb.addEventListener('click', this.handleClick.bind(this));
1110 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1111 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1112 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1114 if ('ontouchstart' in window) {
1115 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1116 window.addEventListener('touchstart', this.closeAllDropdowns);
1119 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1120 sb.addEventListener('focus', this.handleFocus.bind(this));
1122 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1124 window.addEventListener('mouseover', this.setFocus);
1125 window.addEventListener('click', this.closeAllDropdowns);
1129 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1130 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1131 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1133 var li = findParent(create, 'li');
1135 li.setAttribute('unselectable', '');
1136 li.addEventListener('click', this.handleCreateClick.bind(this));
1141 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1142 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1144 dom.bindClassInstance(sb, this);
1150 openDropdown: function(sb) {
1151 var st = window.getComputedStyle(sb, null),
1152 ul = sb.querySelector('ul'),
1153 li = ul.querySelectorAll('li'),
1154 fl = findParent(sb, '.cbi-value-field'),
1155 sel = ul.querySelector('[selected]'),
1156 rect = sb.getBoundingClientRect(),
1157 items = Math.min(this.options.dropdown_items, li.length);
1159 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1160 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1163 sb.setAttribute('open', '');
1165 var pv = ul.cloneNode(true);
1166 pv.classList.add('preview');
1169 fl.classList.add('cbi-dropdown-open');
1171 if ('ontouchstart' in window) {
1172 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1173 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1176 ul.style.top = sb.offsetHeight + 'px';
1177 ul.style.left = -rect.left + 'px';
1178 ul.style.right = (rect.right - vpWidth) + 'px';
1179 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1180 ul.style.WebkitOverflowScrolling = 'touch';
1182 function getScrollParent(element) {
1183 var parent = element,
1184 style = getComputedStyle(element),
1185 excludeStaticParent = (style.position === 'absolute');
1187 if (style.position === 'fixed')
1188 return document.body;
1190 while ((parent = parent.parentElement) != null) {
1191 style = getComputedStyle(parent);
1193 if (excludeStaticParent && style.position === 'static')
1196 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1200 return document.body;
1203 var scrollParent = getScrollParent(sb),
1204 scrollFrom = scrollParent.scrollTop,
1205 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1207 var scrollStep = function(timestamp) {
1210 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1213 var duration = Math.max(timestamp - start, 1);
1214 if (duration < 100) {
1215 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1216 window.requestAnimationFrame(scrollStep);
1219 scrollParent.scrollTop = scrollTo;
1223 window.requestAnimationFrame(scrollStep);
1226 ul.style.maxHeight = '1px';
1227 ul.style.top = ul.style.bottom = '';
1229 window.requestAnimationFrame(function() {
1230 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1232 spaceAbove = rect.top,
1233 spaceBelow = window.innerHeight - rect.height - rect.top;
1235 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1236 fullHeight += li[i].getBoundingClientRect().height;
1238 if (fullHeight <= spaceBelow) {
1239 ul.style.top = rect.height + 'px';
1240 ul.style.maxHeight = spaceBelow + 'px';
1242 else if (fullHeight <= spaceAbove) {
1243 ul.style.bottom = rect.height + 'px';
1244 ul.style.maxHeight = spaceAbove + 'px';
1246 else if (spaceBelow >= spaceAbove) {
1247 ul.style.top = rect.height + 'px';
1248 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1251 ul.style.bottom = rect.height + 'px';
1252 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1255 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1259 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1260 for (var i = 0; i < cboxes.length; i++) {
1261 cboxes[i].checked = true;
1262 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1265 ul.classList.add('dropdown');
1267 sb.insertBefore(pv, ul.nextElementSibling);
1269 li.forEach(function(l) {
1270 l.setAttribute('tabindex', 0);
1273 sb.lastElementChild.setAttribute('tabindex', 0);
1275 this.setFocus(sb, sel || li[0], true);
1279 closeDropdown: function(sb, no_focus) {
1280 if (!sb.hasAttribute('open'))
1283 var pv = sb.querySelector('ul.preview'),
1284 ul = sb.querySelector('ul.dropdown'),
1285 li = ul.querySelectorAll('li'),
1286 fl = findParent(sb, '.cbi-value-field');
1288 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1289 sb.lastElementChild.removeAttribute('tabindex');
1292 sb.removeAttribute('open');
1293 sb.style.width = sb.style.height = '';
1295 ul.classList.remove('dropdown');
1296 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1299 fl.classList.remove('cbi-dropdown-open');
1302 this.setFocus(sb, sb);
1304 this.saveValues(sb, ul);
1308 toggleItem: function(sb, li, force_state) {
1309 if (li.hasAttribute('unselectable'))
1312 if (this.options.multiple) {
1313 var cbox = li.querySelector('input[type="checkbox"]'),
1314 items = li.parentNode.querySelectorAll('li'),
1315 label = sb.querySelector('ul.preview'),
1316 sel = li.parentNode.querySelectorAll('[selected]').length,
1317 more = sb.querySelector('.more'),
1318 ndisplay = this.options.display_items,
1321 if (li.hasAttribute('selected')) {
1322 if (force_state !== true) {
1323 if (sel > 1 || this.options.optional) {
1324 li.removeAttribute('selected');
1325 cbox.checked = cbox.disabled = false;
1329 cbox.disabled = true;
1334 if (force_state !== false) {
1335 li.setAttribute('selected', '');
1336 cbox.checked = true;
1337 cbox.disabled = false;
1342 while (label && label.firstElementChild)
1343 label.removeChild(label.firstElementChild);
1345 for (var i = 0; i < items.length; i++) {
1346 items[i].removeAttribute('display');
1347 if (items[i].hasAttribute('selected')) {
1348 if (ndisplay-- > 0) {
1349 items[i].setAttribute('display', n++);
1351 label.appendChild(items[i].cloneNode(true));
1353 var c = items[i].querySelector('input[type="checkbox"]');
1355 c.disabled = (sel == 1 && !this.options.optional);
1360 sb.setAttribute('more', '');
1362 sb.removeAttribute('more');
1364 if (ndisplay === this.options.display_items)
1365 sb.setAttribute('empty', '');
1367 sb.removeAttribute('empty');
1369 dom.content(more, (ndisplay === this.options.display_items)
1370 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1373 var sel = li.parentNode.querySelector('[selected]');
1375 sel.removeAttribute('display');
1376 sel.removeAttribute('selected');
1379 li.setAttribute('display', 0);
1380 li.setAttribute('selected', '');
1382 this.closeDropdown(sb, true);
1385 this.saveValues(sb, li.parentNode);
1389 transformItem: function(sb, li) {
1390 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1393 while (li.firstChild)
1394 label.appendChild(li.firstChild);
1396 li.appendChild(cbox);
1397 li.appendChild(label);
1401 saveValues: function(sb, ul) {
1402 var sel = ul.querySelectorAll('li[selected]'),
1403 div = sb.lastElementChild,
1404 name = this.options.name,
1408 while (div.lastElementChild)
1409 div.removeChild(div.lastElementChild);
1411 sel.forEach(function (s) {
1412 if (s.hasAttribute('placeholder'))
1417 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1421 div.appendChild(E('input', {
1429 strval += strval.length ? ' ' + v.value : v.value;
1437 if (this.options.multiple)
1438 detail.values = values;
1440 detail.value = values.length ? values[0] : null;
1444 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1451 setValues: function(sb, values) {
1452 var ul = sb.querySelector('ul');
1454 if (this.options.create) {
1455 for (var value in values) {
1456 this.createItems(sb, value);
1458 if (!this.options.multiple)
1463 if (this.options.multiple) {
1464 var lis = ul.querySelectorAll('li[data-value]');
1465 for (var i = 0; i < lis.length; i++) {
1466 var value = lis[i].getAttribute('data-value');
1467 if (values === null || !(value in values))
1468 this.toggleItem(sb, lis[i], false);
1470 this.toggleItem(sb, lis[i], true);
1474 var ph = ul.querySelector('li[placeholder]');
1476 this.toggleItem(sb, ph);
1478 var lis = ul.querySelectorAll('li[data-value]');
1479 for (var i = 0; i < lis.length; i++) {
1480 var value = lis[i].getAttribute('data-value');
1481 if (values !== null && (value in values))
1482 this.toggleItem(sb, lis[i]);
1488 setFocus: function(sb, elem, scroll) {
1489 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1492 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1495 document.querySelectorAll('.focus').forEach(function(e) {
1496 if (!matchesElem(e, 'input')) {
1497 e.classList.remove('focus');
1504 elem.classList.add('focus');
1507 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1512 createChoiceElement: function(sb, value, label) {
1513 var tpl = sb.querySelector(this.options.create_template),
1517 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1519 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1521 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1522 placeholder = new_item.querySelector('[data-label-placeholder]');
1525 var content = E('span', {}, label || this.choices[value] || [ value ]);
1527 while (content.firstChild)
1528 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1530 placeholder.parentNode.removeChild(placeholder);
1533 if (this.options.multiple)
1534 this.transformItem(sb, new_item);
1540 createItems: function(sb, value) {
1542 val = (value || '').trim(),
1543 ul = sb.querySelector('ul');
1545 if (!sbox.options.multiple)
1546 val = val.length ? [ val ] : [];
1548 val = val.length ? val.split(/\s+/) : [];
1550 val.forEach(function(item) {
1551 var new_item = null;
1553 ul.childNodes.forEach(function(li) {
1554 if (li.getAttribute && li.getAttribute('data-value') === item)
1559 new_item = sbox.createChoiceElement(sb, item);
1561 if (!sbox.options.multiple) {
1562 var old = ul.querySelector('li[created]');
1564 ul.removeChild(old);
1566 new_item.setAttribute('created', '');
1569 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1572 sbox.toggleItem(sb, new_item, true);
1573 sbox.setFocus(sb, new_item, true);
1578 * Remove all existing choices from the dropdown menu.
1580 * This function removes all preexisting dropdown choices from the widget,
1581 * keeping only choices currently being selected unless `reset_values` is
1582 * given, in which case all choices and deselected and removed.
1585 * @memberof LuCI.ui.Dropdown
1586 * @param {boolean} [reset_value=false]
1587 * If set to `true`, deselect and remove selected choices as well instead
1590 clearChoices: function(reset_value) {
1591 var ul = this.node.querySelector('ul'),
1592 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1593 len = lis.length - (this.options.create ? 1 : 0),
1594 val = reset_value ? null : this.getValue();
1596 for (var i = 0; i < len; i++) {
1597 var lival = lis[i].getAttribute('data-value');
1599 (!this.options.multiple && val != lival) ||
1600 (this.options.multiple && val.indexOf(lival) == -1))
1601 ul.removeChild(lis[i]);
1605 this.setValues(this.node, {});
1609 * Add new choices to the dropdown menu.
1611 * This function adds further choices to an existing dropdown menu,
1612 * ignoring choice values which are already present.
1615 * @memberof LuCI.ui.Dropdown
1616 * @param {string[]} values
1617 * The choice values to add to the dropdown widget.
1619 * @param {Object<string, *>} labels
1620 * The choice label values to use when adding dropdown choices. If no
1621 * label is found for a particular choice value, the value itself is used
1622 * as label text. Choice labels may be any valid value accepted by
1623 * {@link LuCI.dom#content}.
1625 addChoices: function(values, labels) {
1627 ul = sb.querySelector('ul'),
1628 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1630 if (!Array.isArray(values))
1631 values = L.toArray(values);
1633 if (!L.isObject(labels))
1636 for (var i = 0; i < values.length; i++) {
1639 for (var j = 0; j < lis.length; j++) {
1640 if (lis[j].getAttribute('data-value') === values[i]) {
1650 this.createChoiceElement(sb, values[i], labels[values[i]]),
1651 ul.lastElementChild);
1656 * Close all open dropdown widgets in the current document.
1658 closeAllDropdowns: function() {
1659 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1660 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1665 handleClick: function(ev) {
1666 var sb = ev.currentTarget;
1668 if (!sb.hasAttribute('open')) {
1669 if (!matchesElem(ev.target, 'input'))
1670 this.openDropdown(sb);
1673 var li = findParent(ev.target, 'li');
1674 if (li && li.parentNode.classList.contains('dropdown'))
1675 this.toggleItem(sb, li);
1676 else if (li && li.parentNode.classList.contains('preview'))
1677 this.closeDropdown(sb);
1678 else if (matchesElem(ev.target, 'span.open, span.more'))
1679 this.closeDropdown(sb);
1682 ev.preventDefault();
1683 ev.stopPropagation();
1687 handleKeydown: function(ev) {
1688 var sb = ev.currentTarget;
1690 if (matchesElem(ev.target, 'input'))
1693 if (!sb.hasAttribute('open')) {
1694 switch (ev.keyCode) {
1699 this.openDropdown(sb);
1700 ev.preventDefault();
1704 var active = findParent(document.activeElement, 'li');
1706 switch (ev.keyCode) {
1708 this.closeDropdown(sb);
1713 if (!active.hasAttribute('selected'))
1714 this.toggleItem(sb, active);
1715 this.closeDropdown(sb);
1716 ev.preventDefault();
1722 this.toggleItem(sb, active);
1723 ev.preventDefault();
1728 if (active && active.previousElementSibling) {
1729 this.setFocus(sb, active.previousElementSibling);
1730 ev.preventDefault();
1735 if (active && active.nextElementSibling) {
1736 this.setFocus(sb, active.nextElementSibling);
1737 ev.preventDefault();
1745 handleDropdownClose: function(ev) {
1746 var sb = ev.currentTarget;
1748 this.closeDropdown(sb, true);
1752 handleDropdownSelect: function(ev) {
1753 var sb = ev.currentTarget,
1754 li = findParent(ev.target, 'li');
1759 this.toggleItem(sb, li);
1760 this.closeDropdown(sb, true);
1764 handleMouseover: function(ev) {
1765 var sb = ev.currentTarget;
1767 if (!sb.hasAttribute('open'))
1770 var li = findParent(ev.target, 'li');
1772 if (li && li.parentNode.classList.contains('dropdown'))
1773 this.setFocus(sb, li);
1777 handleFocus: function(ev) {
1778 var sb = ev.currentTarget;
1780 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1781 if (s !== sb || sb.hasAttribute('open'))
1782 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1787 handleCanaryFocus: function(ev) {
1788 this.closeDropdown(ev.currentTarget.parentNode);
1792 handleCreateKeydown: function(ev) {
1793 var input = ev.currentTarget,
1794 sb = findParent(input, '.cbi-dropdown');
1796 switch (ev.keyCode) {
1798 ev.preventDefault();
1800 if (input.classList.contains('cbi-input-invalid'))
1803 this.createItems(sb, input.value);
1811 handleCreateFocus: function(ev) {
1812 var input = ev.currentTarget,
1813 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1814 sb = findParent(input, '.cbi-dropdown');
1817 cbox.checked = true;
1819 sb.setAttribute('locked-in', '');
1823 handleCreateBlur: function(ev) {
1824 var input = ev.currentTarget,
1825 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1826 sb = findParent(input, '.cbi-dropdown');
1829 cbox.checked = false;
1831 sb.removeAttribute('locked-in');
1835 handleCreateClick: function(ev) {
1836 ev.currentTarget.querySelector(this.options.create_query).focus();
1840 setValue: function(values) {
1841 if (this.options.multiple) {
1842 if (!Array.isArray(values))
1843 values = (values != null && values != '') ? [ values ] : [];
1847 for (var i = 0; i < values.length; i++)
1848 v[values[i]] = true;
1850 this.setValues(this.node, v);
1855 if (values != null) {
1856 if (Array.isArray(values))
1857 v[values[0]] = true;
1862 this.setValues(this.node, v);
1867 getValue: function() {
1868 var div = this.node.lastElementChild,
1869 h = div.querySelectorAll('input[type="hidden"]'),
1872 for (var i = 0; i < h.length; i++)
1875 return this.options.multiple ? v : v[0];
1880 * Instantiate a rich dropdown choice widget allowing custom values.
1882 * @constructor Combobox
1884 * @augments LuCI.ui.Dropdown
1888 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1889 * to enter custom values. Historically, comboboxes used to be a dedicated
1890 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1891 * with a set of enforced default properties for easier instantiation.
1893 * UI widget instances are usually not supposed to be created by view code
1894 * directly, instead they're implicitely created by `LuCI.form` when
1895 * instantiating CBI forms.
1897 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1898 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1899 * external JavaScript, use `L.require("ui").then(...)` and access the
1900 * `Combobox` property of the class instance value.
1902 * @param {string|string[]} [value=null]
1903 * The initial input value(s).
1905 * @param {Object<string, *>} choices
1906 * Object containing the selectable choices of the widget. The object keys
1907 * serve as values for the different choices while the values are used as
1910 * @param {LuCI.ui.Combobox.InitOptions} [options]
1911 * Object describing the widget specific options to initialize the dropdown.
1913 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1915 * Comboboxes support the same properties as
1916 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1917 * specific values for the following properties:
1919 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1920 * @memberof LuCI.ui.Combobox
1922 * @property {boolean} multiple=false
1923 * Since Comboboxes never allow selecting multiple values, this property
1924 * is forcibly set to `false`.
1926 * @property {boolean} create=true
1927 * Since Comboboxes always allow custom choice values, this property is
1928 * forcibly set to `true`.
1930 * @property {boolean} optional=true
1931 * Since Comboboxes are always optional, this property is forcibly set to
1934 __init__: function(value, choices, options) {
1935 this.super('__init__', [ value, choices, Object.assign({
1936 select_placeholder: _('-- Please choose --'),
1937 custom_placeholder: _('-- custom --'),
1949 * Instantiate a combo button widget offering multiple action choices.
1951 * @constructor ComboButton
1953 * @augments LuCI.ui.Dropdown
1957 * The `ComboButton` class implements a button element which can be expanded
1958 * into a dropdown to chose from a set of different action choices.
1960 * UI widget instances are usually not supposed to be created by view code
1961 * directly, instead they're implicitely created by `LuCI.form` when
1962 * instantiating CBI forms.
1964 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1965 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1966 * external JavaScript, use `L.require("ui").then(...)` and access the
1967 * `ComboButton` property of the class instance value.
1969 * @param {string|string[]} [value=null]
1970 * The initial input value(s).
1972 * @param {Object<string, *>} choices
1973 * Object containing the selectable choices of the widget. The object keys
1974 * serve as values for the different choices while the values are used as
1977 * @param {LuCI.ui.ComboButton.InitOptions} [options]
1978 * Object describing the widget specific options to initialize the button.
1980 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1982 * ComboButtons support the same properties as
1983 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1984 * specific values for some properties and add aditional button specific
1987 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1988 * @memberof LuCI.ui.ComboButton
1990 * @property {boolean} multiple=false
1991 * Since ComboButtons never allow selecting multiple actions, this property
1992 * is forcibly set to `false`.
1994 * @property {boolean} create=false
1995 * Since ComboButtons never allow creating custom choices, this property
1996 * is forcibly set to `false`.
1998 * @property {boolean} optional=false
1999 * Since ComboButtons must always select one action, this property is
2000 * forcibly set to `false`.
2002 * @property {Object<string, string>} [classes]
2003 * Specifies a mapping of choice values to CSS class names. If an action
2004 * choice is selected by the user and if a corresponding entry exists in
2005 * the `classes` object, the class names corresponding to the selected
2006 * value are set on the button element.
2008 * This is useful to apply different button styles, such as colors, to the
2009 * combined button depending on the selected action.
2011 * @property {function} [click]
2012 * Specifies a handler function to invoke when the user clicks the button.
2013 * This function will be called with the button DOM node as `this` context
2014 * and receive the DOM click event as first as well as the selected action
2015 * choice value as second argument.
2017 __init__: function(value, choices, options) {
2018 this.super('__init__', [ value, choices, Object.assign({
2028 render: function(/* ... */) {
2029 var node = UIDropdown.prototype.render.apply(this, arguments),
2030 val = this.getValue();
2032 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2033 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2039 handleClick: function(ev) {
2040 var sb = ev.currentTarget,
2043 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2044 return UIDropdown.prototype.handleClick.apply(this, arguments);
2046 if (this.options.click)
2047 return this.options.click.call(sb, ev, this.getValue());
2051 toggleItem: function(sb /*, ... */) {
2052 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2053 val = this.getValue();
2055 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2056 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2058 sb.setAttribute('class', 'cbi-dropdown');
2065 * Instantiate a dynamic list widget.
2067 * @constructor DynamicList
2069 * @augments LuCI.ui.AbstractElement
2073 * The `DynamicList` class implements a widget which allows the user to specify
2074 * an arbitrary amount of input values, either from free formed text input or
2075 * from a set of predefined choices.
2077 * UI widget instances are usually not supposed to be created by view code
2078 * directly, instead they're implicitely created by `LuCI.form` when
2079 * instantiating CBI forms.
2081 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2082 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2083 * external JavaScript, use `L.require("ui").then(...)` and access the
2084 * `DynamicList` property of the class instance value.
2086 * @param {string|string[]} [value=null]
2087 * The initial input value(s).
2089 * @param {Object<string, *>} [choices]
2090 * Object containing the selectable choices of the widget. The object keys
2091 * serve as values for the different choices while the values are used as
2092 * choice labels. If omitted, no default choices are presented to the user,
2093 * instead a plain text input field is rendered allowing the user to add
2094 * arbitrary values to the dynamic list.
2096 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2097 * Object describing the widget specific options to initialize the dynamic list.
2099 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2101 * In case choices are passed to the dynamic list contructor, the widget
2102 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2103 * but enforces specific values for some dropdown properties.
2105 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2106 * @memberof LuCI.ui.DynamicList
2108 * @property {boolean} multiple=false
2109 * Since dynamic lists never allow selecting multiple choices when adding
2110 * another list item, this property is forcibly set to `false`.
2112 * @property {boolean} optional=true
2113 * Since dynamic lists use an embedded dropdown to present a list of
2114 * predefined choice values, the dropdown must be made optional to allow
2115 * it to remain unselected.
2117 __init__: function(values, choices, options) {
2118 if (!Array.isArray(values))
2119 values = (values != null && values != '') ? [ values ] : [];
2121 if (typeof(choices) != 'object')
2124 this.values = values;
2125 this.choices = choices;
2126 this.options = Object.assign({}, options, {
2133 render: function() {
2135 'id': this.options.id,
2136 'class': 'cbi-dynlist',
2137 'disabled': this.options.disabled ? '' : null
2138 }, E('div', { 'class': 'add-item' }));
2141 if (this.options.placeholder != null)
2142 this.options.select_placeholder = this.options.placeholder;
2144 var cbox = new UICombobox(null, this.choices, this.options);
2146 dl.lastElementChild.appendChild(cbox.render());
2149 var inputEl = E('input', {
2150 'id': this.options.id ? 'widget.' + this.options.id : null,
2152 'class': 'cbi-input-text',
2153 'placeholder': this.options.placeholder,
2154 'disabled': this.options.disabled ? '' : null
2157 dl.lastElementChild.appendChild(inputEl);
2158 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2160 if (this.options.datatype || this.options.validate)
2161 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2162 true, this.options.validate, 'blur', 'keyup');
2165 for (var i = 0; i < this.values.length; i++) {
2166 var label = this.choices ? this.choices[this.values[i]] : null;
2168 if (dom.elem(label))
2169 label = label.cloneNode(true);
2171 this.addItem(dl, this.values[i], label);
2174 return this.bind(dl);
2178 bind: function(dl) {
2179 dl.addEventListener('click', L.bind(this.handleClick, this));
2180 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2181 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2185 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2186 this.setChangeEvents(dl, 'cbi-dynlist-change');
2188 dom.bindClassInstance(dl, this);
2194 addItem: function(dl, value, text, flash) {
2196 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2197 E('span', {}, [ text || value ]),
2200 'name': this.options.name,
2201 'value': value })]);
2203 dl.querySelectorAll('.item').forEach(function(item) {
2207 var hidden = item.querySelector('input[type="hidden"]');
2209 if (hidden && hidden.parentNode !== item)
2212 if (hidden && hidden.value === value)
2217 var ai = dl.querySelector('.add-item');
2218 ai.parentNode.insertBefore(new_item, ai);
2221 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2233 removeItem: function(dl, item) {
2234 var value = item.querySelector('input[type="hidden"]').value;
2235 var sb = dl.querySelector('.cbi-dropdown');
2237 sb.querySelectorAll('ul > li').forEach(function(li) {
2238 if (li.getAttribute('data-value') === value) {
2239 if (li.hasAttribute('dynlistcustom'))
2240 li.parentNode.removeChild(li);
2242 li.removeAttribute('unselectable');
2246 item.parentNode.removeChild(item);
2248 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2260 handleClick: function(ev) {
2261 var dl = ev.currentTarget,
2262 item = findParent(ev.target, '.item');
2264 if (this.options.disabled)
2268 this.removeItem(dl, item);
2270 else if (matchesElem(ev.target, '.cbi-button-add')) {
2271 var input = ev.target.previousElementSibling;
2272 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2273 this.addItem(dl, input.value, null, true);
2280 handleDropdownChange: function(ev) {
2281 var dl = ev.currentTarget,
2282 sbIn = ev.detail.instance,
2283 sbEl = ev.detail.element,
2284 sbVal = ev.detail.value;
2289 sbIn.setValues(sbEl, null);
2290 sbVal.element.setAttribute('unselectable', '');
2292 if (sbVal.element.hasAttribute('created')) {
2293 sbVal.element.removeAttribute('created');
2294 sbVal.element.setAttribute('dynlistcustom', '');
2297 var label = sbVal.text;
2299 if (sbVal.element) {
2302 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2303 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2306 this.addItem(dl, sbVal.value, label, true);
2310 handleKeydown: function(ev) {
2311 var dl = ev.currentTarget,
2312 item = findParent(ev.target, '.item');
2315 switch (ev.keyCode) {
2316 case 8: /* backspace */
2317 if (item.previousElementSibling)
2318 item.previousElementSibling.focus();
2320 this.removeItem(dl, item);
2323 case 46: /* delete */
2324 if (item.nextElementSibling) {
2325 if (item.nextElementSibling.classList.contains('item'))
2326 item.nextElementSibling.focus();
2328 item.nextElementSibling.firstElementChild.focus();
2331 this.removeItem(dl, item);
2335 else if (matchesElem(ev.target, '.cbi-input-text')) {
2336 switch (ev.keyCode) {
2337 case 13: /* enter */
2338 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2339 this.addItem(dl, ev.target.value, null, true);
2340 ev.target.value = '';
2345 ev.preventDefault();
2352 getValue: function() {
2353 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2354 input = this.node.querySelector('.add-item > input[type="text"]'),
2357 for (var i = 0; i < items.length; i++)
2358 v.push(items[i].value);
2360 if (input && input.value != null && input.value.match(/\S/) &&
2361 input.classList.contains('cbi-input-invalid') == false &&
2362 v.filter(function(s) { return s == input.value }).length == 0)
2363 v.push(input.value);
2369 setValue: function(values) {
2370 if (!Array.isArray(values))
2371 values = (values != null && values != '') ? [ values ] : [];
2373 var items = this.node.querySelectorAll('.item');
2375 for (var i = 0; i < items.length; i++)
2376 if (items[i].parentNode === this.node)
2377 this.removeItem(this.node, items[i]);
2379 for (var i = 0; i < values.length; i++)
2380 this.addItem(this.node, values[i],
2381 this.choices ? this.choices[values[i]] : null);
2385 * Add new suggested choices to the dynamic list.
2387 * This function adds further choices to an existing dynamic list,
2388 * ignoring choice values which are already present.
2391 * @memberof LuCI.ui.DynamicList
2392 * @param {string[]} values
2393 * The choice values to add to the dynamic lists suggestion dropdown.
2395 * @param {Object<string, *>} labels
2396 * The choice label values to use when adding suggested choices. If no
2397 * label is found for a particular choice value, the value itself is used
2398 * as label text. Choice labels may be any valid value accepted by
2399 * {@link LuCI.dom#content}.
2401 addChoices: function(values, labels) {
2402 var dl = this.node.lastElementChild.firstElementChild;
2403 dom.callClassMethod(dl, 'addChoices', values, labels);
2407 * Remove all existing choices from the dynamic list.
2409 * This function removes all preexisting suggested choices from the widget.
2412 * @memberof LuCI.ui.DynamicList
2414 clearChoices: function() {
2415 var dl = this.node.lastElementChild.firstElementChild;
2416 dom.callClassMethod(dl, 'clearChoices');
2421 * Instantiate a hidden input field widget.
2423 * @constructor Hiddenfield
2425 * @augments LuCI.ui.AbstractElement
2429 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2430 * which allows to store form data without exposing it to the user.
2432 * UI widget instances are usually not supposed to be created by view code
2433 * directly, instead they're implicitely created by `LuCI.form` when
2434 * instantiating CBI forms.
2436 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2437 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2438 * external JavaScript, use `L.require("ui").then(...)` and access the
2439 * `Hiddenfield` property of the class instance value.
2441 * @param {string|string[]} [value=null]
2442 * The initial input value.
2444 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2445 * Object describing the widget specific options to initialize the hidden input.
2447 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2448 __init__: function(value, options) {
2450 this.options = Object.assign({
2456 render: function() {
2457 var hiddenEl = E('input', {
2458 'id': this.options.id,
2463 return this.bind(hiddenEl);
2467 bind: function(hiddenEl) {
2468 this.node = hiddenEl;
2470 dom.bindClassInstance(hiddenEl, this);
2476 getValue: function() {
2477 return this.node.value;
2481 setValue: function(value) {
2482 this.node.value = value;
2487 * Instantiate a file upload widget.
2489 * @constructor FileUpload
2491 * @augments LuCI.ui.AbstractElement
2495 * The `FileUpload` class implements a widget which allows the user to upload,
2496 * browse, select and delete files beneath a predefined remote directory.
2498 * UI widget instances are usually not supposed to be created by view code
2499 * directly, instead they're implicitely created by `LuCI.form` when
2500 * instantiating CBI forms.
2502 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2503 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2504 * external JavaScript, use `L.require("ui").then(...)` and access the
2505 * `FileUpload` property of the class instance value.
2507 * @param {string|string[]} [value=null]
2508 * The initial input value.
2510 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2511 * Object describing the widget specific options to initialize the file
2514 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2516 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2517 * the following properties are recognized:
2519 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2520 * @memberof LuCI.ui.FileUpload
2522 * @property {boolean} [show_hidden=false]
2523 * Specifies whether hidden files should be displayed when browsing remote
2524 * files. Note that this is not a security feature, hidden files are always
2525 * present in the remote file listings received, this option merely controls
2526 * whether they're displayed or not.
2528 * @property {boolean} [enable_upload=true]
2529 * Specifies whether the widget allows the user to upload files. If set to
2530 * `false`, only existing files may be selected. Note that this is not a
2531 * security feature. Whether file upload requests are accepted remotely
2532 * depends on the ACL setup for the current session. This option merely
2533 * controls whether the upload controls are rendered or not.
2535 * @property {boolean} [enable_remove=true]
2536 * Specifies whether the widget allows the user to delete remove files.
2537 * If set to `false`, existing files may not be removed. Note that this is
2538 * not a security feature. Whether file delete requests are accepted
2539 * remotely depends on the ACL setup for the current session. This option
2540 * merely controls whether the file remove controls are rendered or not.
2542 * @property {string} [root_directory=/etc/luci-uploads]
2543 * Specifies the remote directory the upload and file browsing actions take
2544 * place in. Browsing to directories outside of the root directory is
2545 * prevented by the widget. Note that this is not a security feature.
2546 * Whether remote directories are browseable or not solely depends on the
2547 * ACL setup for the current session.
2549 __init__: function(value, options) {
2551 this.options = Object.assign({
2553 enable_upload: true,
2554 enable_remove: true,
2555 root_directory: '/etc/luci-uploads'
2560 bind: function(browserEl) {
2561 this.node = browserEl;
2563 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2564 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2566 dom.bindClassInstance(browserEl, this);
2572 render: function() {
2573 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2576 if (L.isObject(stat) && stat.type != 'directory')
2579 if (this.stat != null)
2580 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2581 else if (this.value != null)
2582 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2584 label = [ _('Select file…') ];
2586 return this.bind(E('div', { 'id': this.options.id }, [
2589 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2590 'disabled': this.options.disabled ? '' : null
2593 'class': 'cbi-filebrowser'
2597 'name': this.options.name,
2605 truncatePath: function(path) {
2606 if (path.length > 50)
2607 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2613 iconForType: function(type) {
2617 'src': L.resource('cbi/link.gif'),
2618 'title': _('Symbolic link'),
2624 'src': L.resource('cbi/folder.gif'),
2625 'title': _('Directory'),
2631 'src': L.resource('cbi/file.gif'),
2639 canonicalizePath: function(path) {
2640 return path.replace(/\/{2,}/, '/')
2641 .replace(/\/\.(\/|$)/g, '/')
2642 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2643 .replace(/\/$/, '');
2647 splitPath: function(path) {
2648 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2649 cpath = this.canonicalizePath(path || '/');
2651 if (cpath.length <= croot.length)
2654 if (cpath.charAt(croot.length) != '/')
2657 var parts = cpath.substring(croot.length + 1).split(/\//);
2659 parts.unshift(croot);
2665 handleUpload: function(path, list, ev) {
2666 var form = ev.target.parentNode,
2667 fileinput = form.querySelector('input[type="file"]'),
2668 nameinput = form.querySelector('input[type="text"]'),
2669 filename = (nameinput.value != null ? nameinput.value : '').trim();
2671 ev.preventDefault();
2673 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2676 var existing = list.filter(function(e) { return e.name == filename })[0];
2678 if (existing != null && existing.type == 'directory')
2679 return alert(_('A directory with the same name already exists.'));
2680 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2683 var data = new FormData();
2685 data.append('sessionid', L.env.sessionid);
2686 data.append('filename', path + '/' + filename);
2687 data.append('filedata', fileinput.files[0]);
2689 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2690 progress: L.bind(function(btn, ev) {
2691 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2693 }).then(L.bind(function(path, ev, res) {
2694 var reply = res.json();
2696 if (L.isObject(reply) && reply.failure)
2697 alert(_('Upload request failed: %s').format(reply.message));
2699 return this.handleSelect(path, null, ev);
2700 }, this, path, ev));
2704 handleDelete: function(path, fileStat, ev) {
2705 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2706 name = path.replace(/^.+\//, ''),
2709 ev.preventDefault();
2711 if (fileStat.type == 'directory')
2712 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2714 msg = _('Do you really want to delete "%s" ?').format(name);
2717 var button = this.node.firstElementChild,
2718 hidden = this.node.lastElementChild;
2720 if (path == hidden.value) {
2721 dom.content(button, _('Select file…'));
2725 return fs.remove(path).then(L.bind(function(parent, ev) {
2726 return this.handleSelect(parent, null, ev);
2727 }, this, parent, ev)).catch(function(err) {
2728 alert(_('Delete request failed: %s').format(err.message));
2734 renderUpload: function(path, list) {
2735 if (!this.options.enable_upload)
2741 'class': 'btn cbi-button-positive',
2742 'click': function(ev) {
2743 var uploadForm = ev.target.nextElementSibling,
2744 fileInput = uploadForm.querySelector('input[type="file"]');
2746 ev.target.style.display = 'none';
2747 uploadForm.style.display = '';
2750 }, _('Upload file…')),
2751 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2754 'style': 'display:none',
2755 'change': function(ev) {
2756 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2757 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2759 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2760 uploadbtn.disabled = false;
2765 'click': function(ev) {
2766 ev.preventDefault();
2767 ev.target.previousElementSibling.click();
2769 }, [ _('Browse…') ]),
2770 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2772 'class': 'btn cbi-button-save',
2773 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2775 }, [ _('Upload file') ])
2781 renderListing: function(container, path, list) {
2782 var breadcrumb = E('p'),
2785 list.sort(function(a, b) {
2786 var isDirA = (a.type == 'directory'),
2787 isDirB = (b.type == 'directory');
2789 if (isDirA != isDirB)
2790 return isDirA < isDirB;
2792 return a.name > b.name;
2795 for (var i = 0; i < list.length; i++) {
2796 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2799 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2800 selected = (entrypath == this.node.lastElementChild.value),
2801 mtime = new Date(list[i].mtime * 1000);
2803 rows.appendChild(E('li', [
2804 E('div', { 'class': 'name' }, [
2805 this.iconForType(list[i].type),
2809 'style': selected ? 'font-weight:bold' : null,
2810 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2811 entrypath, list[i].type != 'directory' ? list[i] : null)
2812 }, '%h'.format(list[i].name))
2814 E('div', { 'class': 'mtime hide-xs' }, [
2815 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2816 mtime.getFullYear(),
2817 mtime.getMonth() + 1,
2824 selected ? E('button', {
2826 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2827 }, [ _('Deselect') ]) : '',
2828 this.options.enable_remove ? E('button', {
2829 'class': 'btn cbi-button-negative',
2830 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2831 }, [ _('Delete') ]) : ''
2836 if (!rows.firstElementChild)
2837 rows.appendChild(E('em', _('No entries in this directory')));
2839 var dirs = this.splitPath(path),
2842 for (var i = 0; i < dirs.length; i++) {
2843 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2844 dom.append(breadcrumb, [
2848 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2849 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2853 dom.content(container, [
2856 E('div', { 'class': 'right' }, [
2857 this.renderUpload(path, list),
2861 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2868 handleCancel: function(ev) {
2869 var button = this.node.firstElementChild,
2870 browser = button.nextElementSibling;
2872 browser.classList.remove('open');
2873 button.style.display = '';
2875 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2877 ev.preventDefault();
2881 handleReset: function(ev) {
2882 var button = this.node.firstElementChild,
2883 hidden = this.node.lastElementChild;
2886 dom.content(button, _('Select file…'));
2888 this.handleCancel(ev);
2892 handleSelect: function(path, fileStat, ev) {
2893 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2894 ul = browser.querySelector('ul');
2896 if (fileStat == null) {
2897 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2898 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2901 var button = this.node.firstElementChild,
2902 hidden = this.node.lastElementChild;
2904 path = this.canonicalizePath(path);
2906 dom.content(button, [
2907 this.iconForType(fileStat.type),
2908 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2911 browser.classList.remove('open');
2912 button.style.display = '';
2913 hidden.value = path;
2915 this.stat = Object.assign({ path: path }, fileStat);
2916 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2921 handleFileBrowser: function(ev) {
2922 var button = ev.target,
2923 browser = button.nextElementSibling,
2924 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2926 if (path.indexOf(this.options.root_directory) != 0)
2927 path = this.options.root_directory;
2929 ev.preventDefault();
2931 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2932 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2933 dom.findClassInstance(browserEl).handleCancel(ev);
2936 button.style.display = 'none';
2937 browser.classList.add('open');
2939 return this.renderListing(browser, path, list);
2940 }, this, button, browser, path));
2944 getValue: function() {
2945 return this.node.lastElementChild.value;
2949 setValue: function(value) {
2950 this.node.lastElementChild.value = value;
2960 * Provides high level UI helper functionality.
2961 * To import the class in views, use `'require ui'`, to import it in
2962 * external JavaScript, use `L.require("ui").then(...)`.
2964 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
2965 __init__: function() {
2966 modalDiv = document.body.appendChild(
2967 dom.create('div', { id: 'modal_overlay' },
2968 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
2970 tooltipDiv = document.body.appendChild(
2971 dom.create('div', { class: 'cbi-tooltip' }));
2973 /* setup old aliases */
2974 L.showModal = this.showModal;
2975 L.hideModal = this.hideModal;
2976 L.showTooltip = this.showTooltip;
2977 L.hideTooltip = this.hideTooltip;
2978 L.itemlist = this.itemlist;
2980 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
2981 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
2982 document.addEventListener('focus', this.showTooltip.bind(this), true);
2983 document.addEventListener('blur', this.hideTooltip.bind(this), true);
2985 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
2986 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
2987 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
2991 * Display a modal overlay dialog with the specified contents.
2993 * The modal overlay dialog covers the current view preventing interaction
2994 * with the underlying view contents. Only one modal dialog instance can
2995 * be opened. Invoking showModal() while a modal dialog is already open will
2996 * replace the open dialog with a new one having the specified contents.
2998 * Additional CSS class names may be passed to influence the appearence of
2999 * the dialog. Valid values for the classes depend on the underlying theme.
3001 * @see LuCI.dom.content
3003 * @param {string} [title]
3004 * The title of the dialog. If `null`, no title element will be rendered.
3006 * @param {*} contents
3007 * The contents to add to the modal dialog. This should be a DOM node or
3008 * a document fragment in most cases. The value is passed as-is to the
3009 * `dom.content()` function - refer to its documentation for applicable
3012 * @param {...string} [classes]
3013 * A number of extra CSS class names which are set on the modal dialog
3017 * Returns a DOM Node representing the modal dialog element.
3019 showModal: function(title, children /* , ... */) {
3020 var dlg = modalDiv.firstElementChild;
3022 dlg.setAttribute('class', 'modal');
3024 for (var i = 2; i < arguments.length; i++)
3025 dlg.classList.add(arguments[i]);
3027 dom.content(dlg, dom.create('h4', {}, title));
3028 dom.append(dlg, children);
3030 document.body.classList.add('modal-overlay-active');
3036 * Close the open modal overlay dialog.
3038 * This function will close an open modal dialog and restore the normal view
3039 * behaviour. It has no effect if no modal dialog is currently open.
3041 * Note that this function is stand-alone, it does not rely on `this` and
3042 * will not invoke other class functions so it suitable to be used as event
3043 * handler as-is without the need to bind it first.
3045 hideModal: function() {
3046 document.body.classList.remove('modal-overlay-active');
3050 showTooltip: function(ev) {
3051 var target = findParent(ev.target, '[data-tooltip]');
3056 if (tooltipTimeout !== null) {
3057 window.clearTimeout(tooltipTimeout);
3058 tooltipTimeout = null;
3061 var rect = target.getBoundingClientRect(),
3062 x = rect.left + window.pageXOffset,
3063 y = rect.top + rect.height + window.pageYOffset;
3065 tooltipDiv.className = 'cbi-tooltip';
3066 tooltipDiv.innerHTML = 'â–² ';
3067 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3069 if (target.hasAttribute('data-tooltip-style'))
3070 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3072 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3073 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3074 tooltipDiv.firstChild.data = 'â–¼ ' + tooltipDiv.firstChild.data.substr(2);
3077 tooltipDiv.style.top = y + 'px';
3078 tooltipDiv.style.left = x + 'px';
3079 tooltipDiv.style.opacity = 1;
3081 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3083 detail: { target: target }
3088 hideTooltip: function(ev) {
3089 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3090 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3093 if (tooltipTimeout !== null) {
3094 window.clearTimeout(tooltipTimeout);
3095 tooltipTimeout = null;
3098 tooltipDiv.style.opacity = 0;
3099 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3101 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3105 * Add a notification banner at the top of the current view.
3107 * A notification banner is an alert message usually displayed at the
3108 * top of the current view, spanning the entire availibe width.
3109 * Notification banners will stay in place until dismissed by the user.
3110 * Multiple banners may be shown at the same time.
3112 * Additional CSS class names may be passed to influence the appearence of
3113 * the banner. Valid values for the classes depend on the underlying theme.
3115 * @see LuCI.dom.content
3117 * @param {string} [title]
3118 * The title of the notification banner. If `null`, no title element
3121 * @param {*} contents
3122 * The contents to add to the notification banner. This should be a DOM
3123 * node or a document fragment in most cases. The value is passed as-is
3124 * to the `dom.content()` function - refer to its documentation for
3125 * applicable values.
3127 * @param {...string} [classes]
3128 * A number of extra CSS class names which are set on the notification
3132 * Returns a DOM Node representing the notification banner element.
3134 addNotification: function(title, children /*, ... */) {
3135 var mc = document.querySelector('#maincontent') || document.body;
3136 var msg = E('div', {
3137 'class': 'alert-message fade-in',
3138 'style': 'display:flex',
3139 'transitionend': function(ev) {
3140 var node = ev.currentTarget;
3141 if (node.parentNode && node.classList.contains('fade-out'))
3142 node.parentNode.removeChild(node);
3145 E('div', { 'style': 'flex:10' }),
3146 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3149 'style': 'margin-left:auto; margin-top:auto',
3150 'click': function(ev) {
3151 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3154 }, [ _('Dismiss') ])
3159 dom.append(msg.firstElementChild, E('h4', {}, title));
3161 dom.append(msg.firstElementChild, children);
3163 for (var i = 2; i < arguments.length; i++)
3164 msg.classList.add(arguments[i]);
3166 mc.insertBefore(msg, mc.firstElementChild);
3172 * Display or update an header area indicator.
3174 * An indicator is a small label displayed in the header area of the screen
3175 * providing few amounts of status information such as item counts or state
3176 * toggle indicators.
3178 * Multiple indicators may be shown at the same time and indicator labels
3179 * may be made clickable to display extended information or to initiate
3182 * Indicators can either use a default `active` or a less accented `inactive`
3183 * style which is useful for indicators representing state toggles.
3185 * @param {string} id
3186 * The ID of the indicator. If an indicator with the given ID already exists,
3187 * it is updated with the given label and style.
3189 * @param {string} label
3190 * The text to display in the indicator label.
3192 * @param {function} [handler]
3193 * A handler function to invoke when the indicator label is clicked/touched
3194 * by the user. If omitted, the indicator is not clickable/touchable.
3196 * Note that this parameter only applies to new indicators, when updating
3197 * existing labels it is ignored.
3199 * @param {string} [style=active]
3200 * The indicator style to use. May be either `active` or `inactive`.
3202 * @returns {boolean}
3203 * Returns `true` when the indicator has been updated or `false` when no
3204 * changes were made.
3206 showIndicator: function(id, label, handler, style) {
3207 if (indicatorDiv == null) {
3208 indicatorDiv = document.body.querySelector('#indicators');
3210 if (indicatorDiv == null)
3214 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3215 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id));
3217 if (indicatorElem == null) {
3218 var beforeElem = null;
3220 for (beforeElem = indicatorDiv.firstElementChild;
3222 beforeElem = beforeElem.nextElementSibling)
3223 if (beforeElem.getAttribute('data-indicator') > id)
3226 indicatorElem = indicatorDiv.insertBefore(E('span', {
3227 'data-indicator': id,
3228 'data-clickable': handlerFn ? true : null,
3230 }, ['']), beforeElem);
3233 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3236 indicatorElem.firstChild.data = label;
3237 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3242 * Remove an header area indicator.
3244 * This function removes the given indicator label from the header indicator
3245 * area. When the given indicator is not found, this function does nothing.
3247 * @param {string} id
3248 * The ID of the indicator to remove.
3250 * @returns {boolean}
3251 * Returns `true` when the indicator has been removed or `false` when the
3252 * requested indicator was not found.
3254 hideIndicator: function(id) {
3255 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3257 if (indicatorElem == null)
3260 indicatorDiv.removeChild(indicatorElem);
3265 * Formats a series of label/value pairs into list-like markup.
3267 * This function transforms a flat array of alternating label and value
3268 * elements into a list-like markup, using the values in `separators` as
3269 * separators and appends the resulting nodes to the given parent DOM node.
3271 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3272 * `<strong>` element and the value corresponding to the label are
3273 * subsequently wrapped into a `<span class="nowrap">` element.
3275 * The resulting `<span>` element tuples are joined by the given separators
3276 * to form the final markup which is appened to the given parent DOM node.
3278 * @param {Node} node
3279 * The parent DOM node to append the markup to. Any previous child elements
3282 * @param {Array<*>} items
3283 * An alternating array of labels and values. The label values will be
3284 * converted to plain strings, the values are used as-is and may be of
3285 * any type accepted by `LuCI.dom.content()`.
3287 * @param {*|Array<*>} [separators=[E('br')]]
3288 * A single value or an array of separator values to separate each
3289 * label/value pair with. The function will cycle through the separators
3290 * when joining the pairs. If omitted, the default separator is a sole HTML
3291 * `<br>` element. Separator values are used as-is and may be of any type
3292 * accepted by `LuCI.dom.content()`.
3295 * Returns the parent DOM node the formatted markup has been added to.
3297 itemlist: function(node, items, separators) {
3300 if (!Array.isArray(separators))
3301 separators = [ separators || E('br') ];
3303 for (var i = 0; i < items.length; i += 2) {
3304 if (items[i+1] !== null && items[i+1] !== undefined) {
3305 var sep = separators[(i/2) % separators.length],
3308 children.push(E('span', { class: 'nowrap' }, [
3309 items[i] ? E('strong', items[i] + ': ') : '',
3313 if ((i+2) < items.length)
3314 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3318 dom.content(node, children);
3329 * The `tabs` class handles tab menu groups used throughout the view area.
3330 * It takes care of setting up tab groups, tracking their state and handling
3333 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3334 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3335 * external JavaScript, use `L.require("ui").then(...)` and access the
3336 * `tabs` property of the class instance value.
3338 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3341 var groups = [], prevGroup = null, currGroup = null;
3343 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3344 var parent = tab.parentNode;
3346 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3349 if (!parent.hasAttribute('data-tab-group'))
3350 parent.setAttribute('data-tab-group', groups.length);
3352 currGroup = +parent.getAttribute('data-tab-group');
3354 if (currGroup !== prevGroup) {
3355 prevGroup = currGroup;
3357 if (!groups[currGroup])
3358 groups[currGroup] = [];
3361 groups[currGroup].push(tab);
3364 for (var i = 0; i < groups.length; i++)
3365 this.initTabGroup(groups[i]);
3367 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3373 * Initializes a new tab group from the given tab pane collection.
3375 * This function cycles through the given tab pane DOM nodes, extracts
3376 * their tab IDs, titles and active states, renders a corresponding
3377 * tab menu and prepends it to the tab panes common parent DOM node.
3379 * The tab menu labels will be set to the value of the `data-tab-title`
3380 * attribute of each corresponding pane. The last pane with the
3381 * `data-tab-active` attribute set to `true` will be selected by default.
3383 * If no pane is marked as active, the first one will be preselected.
3386 * @memberof LuCI.ui.tabs
3387 * @param {Array<Node>|NodeList} panes
3388 * A collection of tab panes to build a tab group menu for. May be a
3389 * plain array of DOM nodes or a NodeList collection, such as the result
3390 * of a `querySelectorAll()` call or the `.childNodes` property of a
3393 initTabGroup: function(panes) {
3394 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3397 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3398 group = panes[0].parentNode,
3399 groupId = +group.getAttribute('data-tab-group'),
3402 if (group.getAttribute('data-initialized') === 'true')
3405 for (var i = 0, pane; pane = panes[i]; i++) {
3406 var name = pane.getAttribute('data-tab'),
3407 title = pane.getAttribute('data-tab-title'),
3408 active = pane.getAttribute('data-tab-active') === 'true';
3410 menu.appendChild(E('li', {
3411 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3412 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3416 'click': this.switchTab.bind(this)
3423 group.parentNode.insertBefore(menu, group);
3424 group.setAttribute('data-initialized', true);
3426 if (selected === null) {
3427 selected = this.getActiveTabId(panes[0]);
3429 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3430 for (var i = 0; i < panes.length; i++) {
3431 if (!this.isEmptyPane(panes[i])) {
3438 menu.childNodes[selected].classList.add('cbi-tab');
3439 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3440 panes[selected].setAttribute('data-tab-active', 'true');
3442 this.setActiveTabId(panes[selected], selected);
3445 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3446 detail: { tab: panes[selected].getAttribute('data-tab') }
3449 this.updateTabs(group);
3453 * Checks whether the given tab pane node is empty.
3456 * @memberof LuCI.ui.tabs
3457 * @param {Node} pane
3458 * The tab pane to check.
3460 * @returns {boolean}
3461 * Returns `true` if the pane is empty, else `false`.
3463 isEmptyPane: function(pane) {
3464 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3468 getPathForPane: function(pane) {
3469 var path = [], node = null;
3471 for (node = pane ? pane.parentNode : null;
3472 node != null && node.hasAttribute != null;
3473 node = node.parentNode)
3475 if (node.hasAttribute('data-tab'))
3476 path.unshift(node.getAttribute('data-tab'));
3477 else if (node.hasAttribute('data-section-id'))
3478 path.unshift(node.getAttribute('data-section-id'));
3481 return path.join('/');
3485 getActiveTabState: function() {
3486 var page = document.body.getAttribute('data-page'),
3487 state = session.getLocalData('tab');
3489 if (L.isObject(state) && state.page === page && L.isObject(state.paths))
3492 session.setLocalData('tab', null);
3494 return { page: page, paths: {} };
3498 getActiveTabId: function(pane) {
3499 var path = this.getPathForPane(pane);
3500 return +this.getActiveTabState().paths[path] || 0;
3504 setActiveTabId: function(pane, tabIndex) {
3505 var path = this.getPathForPane(pane),
3506 state = this.getActiveTabState();
3508 state.paths[path] = tabIndex;
3510 return session.setLocalData('tab', state);
3514 updateTabs: function(ev, root) {
3515 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3516 var menu = pane.parentNode.previousElementSibling,
3517 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3518 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3523 if (this.isEmptyPane(pane)) {
3524 tab.style.display = 'none';
3525 tab.classList.remove('flash');
3527 else if (tab.style.display === 'none') {
3528 tab.style.display = '';
3529 requestAnimationFrame(function() { tab.classList.add('flash') });
3533 tab.setAttribute('data-errors', n_errors);
3534 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3535 tab.setAttribute('data-tooltip-style', 'error');
3538 tab.removeAttribute('data-errors');
3539 tab.removeAttribute('data-tooltip');
3545 switchTab: function(ev) {
3546 var tab = ev.target.parentNode,
3547 name = tab.getAttribute('data-tab'),
3548 menu = tab.parentNode,
3549 group = menu.nextElementSibling,
3550 groupId = +group.getAttribute('data-tab-group'),
3553 ev.preventDefault();
3555 if (!tab.classList.contains('cbi-tab-disabled'))
3558 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3559 tab.classList.remove('cbi-tab');
3560 tab.classList.remove('cbi-tab-disabled');
3562 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3565 group.childNodes.forEach(function(pane) {
3566 if (dom.matches(pane, '[data-tab]')) {
3567 if (pane.getAttribute('data-tab') === name) {
3568 pane.setAttribute('data-tab-active', 'true');
3569 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3570 UI.prototype.tabs.setActiveTabId(pane, index);
3573 pane.setAttribute('data-tab-active', 'false');
3583 * @typedef {Object} FileUploadReply
3586 * @property {string} name - Name of the uploaded file without directory components
3587 * @property {number} size - Size of the uploaded file in bytes
3588 * @property {string} checksum - The MD5 checksum of the received file data
3589 * @property {string} sha256sum - The SHA256 checksum of the received file data
3593 * Display a modal file upload prompt.
3595 * This function opens a modal dialog prompting the user to select and
3596 * upload a file to a predefined remote destination path.
3598 * @param {string} path
3599 * The remote file path to upload the local file to.
3601 * @param {Node} [progessStatusNode]
3602 * An optional DOM text node whose content text is set to the progress
3603 * percentage value during file upload.
3605 * @returns {Promise<LuCI.ui.FileUploadReply>}
3606 * Returns a promise resolving to a file upload status object on success
3607 * or rejecting with an error in case the upload failed or has been
3608 * cancelled by the user.
3610 uploadFile: function(path, progressStatusNode) {
3611 return new Promise(function(resolveFn, rejectFn) {
3612 UI.prototype.showModal(_('Uploading file…'), [
3613 E('p', _('Please select the file to upload.')),
3614 E('div', { 'style': 'display:flex' }, [
3615 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3618 style: 'display:none',
3619 change: function(ev) {
3620 var modal = dom.parent(ev.target, '.modal'),
3621 body = modal.querySelector('p'),
3622 upload = modal.querySelector('.cbi-button-action.important'),
3623 file = ev.currentTarget.files[0];
3630 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3631 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3635 upload.disabled = false;
3641 'click': function(ev) {
3642 ev.target.previousElementSibling.click();
3644 }, [ _('Browse…') ])
3646 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3649 'click': function() {
3650 UI.prototype.hideModal();
3651 rejectFn(new Error('Upload has been cancelled'));
3653 }, [ _('Cancel') ]),
3656 'class': 'btn cbi-button-action important',
3658 'click': function(ev) {
3659 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3661 if (!input.files[0])
3664 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3666 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3668 var data = new FormData();
3670 data.append('sessionid', rpc.getSessionID());
3671 data.append('filename', path);
3672 data.append('filedata', input.files[0]);
3674 var filename = input.files[0].name;
3676 request.post(L.env.cgi_base + '/cgi-upload', data, {
3678 progress: function(pev) {
3679 var percent = (pev.loaded / pev.total) * 100;
3681 if (progressStatusNode)
3682 progressStatusNode.data = '%.2f%%'.format(percent);
3684 progress.setAttribute('title', '%.2f%%'.format(percent));
3685 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3687 }).then(function(res) {
3688 var reply = res.json();
3690 UI.prototype.hideModal();
3692 if (L.isObject(reply) && reply.failure) {
3693 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3694 rejectFn(new Error(reply.failure));
3697 reply.name = filename;
3701 UI.prototype.hideModal();
3713 * Perform a device connectivity test.
3715 * Attempt to fetch a well known ressource from the remote device via HTTP
3716 * in order to test connectivity. This function is mainly useful to wait
3717 * for the router to come back online after a reboot or reconfiguration.
3719 * @param {string} [proto=http]
3720 * The protocol to use for fetching the resource. May be either `http`
3721 * (the default) or `https`.
3723 * @param {string} [host=window.location.host]
3724 * Override the host address to probe. By default the current host as seen
3725 * in the address bar is probed.
3727 * @returns {Promise<Event>}
3728 * Returns a promise resolving to a `load` event in case the device is
3729 * reachable or rejecting with an `error` event in case it is not reachable
3730 * or rejecting with `null` when the connectivity check timed out.
3732 pingDevice: function(proto, ipaddr) {
3733 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3735 return new Promise(function(resolveFn, rejectFn) {
3736 var img = new Image();
3738 img.onload = resolveFn;
3739 img.onerror = rejectFn;
3741 window.setTimeout(rejectFn, 1000);
3748 * Wait for device to come back online and reconnect to it.
3750 * Poll each given hostname or IP address and navigate to it as soon as
3751 * one of the addresses becomes reachable.
3753 * @param {...string} [hosts=[window.location.host]]
3754 * The list of IP addresses and host names to check for reachability.
3755 * If omitted, the current value of `window.location.host` is used by
3758 awaitReconnect: function(/* ... */) {
3759 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3761 window.setTimeout(L.bind(function() {
3762 poll.add(L.bind(function() {
3763 var tasks = [], reachable = false;
3765 for (var i = 0; i < 2; i++)
3766 for (var j = 0; j < ipaddrs.length; j++)
3767 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3768 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3770 return Promise.all(tasks).then(function() {
3773 window.location = reachable;
3786 * The `changes` class encapsulates logic for visualizing, applying,
3787 * confirming and reverting staged UCI changesets.
3789 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3790 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3791 * external JavaScript, use `L.require("ui").then(...)` and access the
3792 * `changes` property of the class instance value.
3794 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3796 if (!L.env.sessionid)
3799 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3803 * Set the change count indicator.
3805 * This function updates or hides the UCI change count indicator,
3806 * depending on the passed change count. When the count is greater
3807 * than 0, the change indicator is displayed or updated, otherwise it
3811 * @memberof LuCI.ui.changes
3812 * @param {number} numChanges
3813 * The number of changes to indicate.
3815 setIndicator: function(n) {
3816 var i = document.querySelector('.uci_change_indicator');
3818 var poll = document.getElementById('xhr_poll_status');
3819 i = poll.parentNode.insertBefore(E('a', {
3821 'class': 'uci_change_indicator label notice',
3822 'click': L.bind(this.displayChanges, this)
3827 dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
3828 i.classList.add('flash');
3829 i.style.display = '';
3830 document.dispatchEvent(new CustomEvent('uci-new-changes'));
3833 i.classList.remove('flash');
3834 i.style.display = 'none';
3835 document.dispatchEvent(new CustomEvent('uci-clear-changes'));
3840 * Update the change count indicator.
3842 * This function updates the UCI change count indicator from the given
3843 * UCI changeset structure.
3846 * @memberof LuCI.ui.changes
3847 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3848 * The UCI changeset to count.
3850 renderChangeIndicator: function(changes) {
3853 for (var config in changes)
3854 if (changes.hasOwnProperty(config))
3855 n_changes += changes[config].length;
3857 this.changes = changes;
3858 this.setIndicator(n_changes);
3863 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3864 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3865 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3866 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
3867 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3868 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3869 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3870 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3871 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
3872 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3876 * Display the current changelog.
3878 * Open a modal dialog visualizing the currently staged UCI changes
3879 * and offer options to revert or apply the shown changes.
3882 * @memberof LuCI.ui.changes
3884 displayChanges: function() {
3885 var list = E('div', { 'class': 'uci-change-list' }),
3886 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3887 E('div', { 'class': 'cbi-section' }, [
3888 E('strong', _('Legend:')),
3889 E('div', { 'class': 'uci-change-legend' }, [
3890 E('div', { 'class': 'uci-change-legend-label' }, [
3891 E('ins', ' '), ' ', _('Section added') ]),
3892 E('div', { 'class': 'uci-change-legend-label' }, [
3893 E('del', ' '), ' ', _('Section removed') ]),
3894 E('div', { 'class': 'uci-change-legend-label' }, [
3895 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
3896 E('div', { 'class': 'uci-change-legend-label' }, [
3897 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
3899 E('div', { 'class': 'right' }, [
3902 'click': UI.prototype.hideModal
3903 }, [ _('Dismiss') ]), ' ',
3905 'class': 'cbi-button cbi-button-positive important',
3906 'click': L.bind(this.apply, this, true)
3907 }, [ _('Save & Apply') ]), ' ',
3909 'class': 'cbi-button cbi-button-reset',
3910 'click': L.bind(this.revert, this)
3911 }, [ _('Revert') ])])])
3914 for (var config in this.changes) {
3915 if (!this.changes.hasOwnProperty(config))
3918 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
3920 for (var i = 0, added = null; i < this.changes[config].length; i++) {
3921 var chg = this.changes[config][i],
3922 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
3924 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
3930 if (added != null && chg[1] == added[0])
3931 return '@' + added[1] + '[-1]';
3936 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
3943 if (chg[0] == 'add')
3944 added = [ chg[1], chg[2] ];
3948 list.appendChild(E('br'));
3949 dlg.classList.add('uci-dialog');
3953 displayStatus: function(type, content) {
3955 var message = UI.prototype.showModal('', '');
3957 message.classList.add('alert-message');
3958 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
3961 dom.content(message, content);
3963 if (!this.was_polling) {
3964 this.was_polling = request.poll.active();
3965 request.poll.stop();
3969 UI.prototype.hideModal();
3971 if (this.was_polling)
3972 request.poll.start();
3977 rollback: function(checked) {
3979 this.displayStatus('warning spinning',
3980 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
3981 .format(L.env.apply_rollback)));
3983 var call = function(r, data, duration) {
3984 if (r.status === 204) {
3985 UI.prototype.changes.displayStatus('warning', [
3986 E('h4', _('Configuration changes have been rolled back!')),
3987 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)),
3988 E('div', { 'class': 'right' }, [
3991 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
3992 }, [ _('Dismiss') ]), ' ',
3994 'class': 'btn cbi-button-action important',
3995 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
3996 }, [ _('Revert changes') ]), ' ',
3998 'class': 'btn cbi-button-negative important',
3999 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4000 }, [ _('Apply unchecked') ])
4007 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4008 window.setTimeout(function() {
4009 request.request(L.url('admin/uci/confirm'), {
4011 timeout: L.env.apply_timeout * 1000,
4012 query: { sid: L.env.sessionid, token: L.env.token }
4017 call({ status: 0 });
4020 this.displayStatus('warning', [
4021 E('h4', _('Device unreachable!')),
4022 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.'))
4028 confirm: function(checked, deadline, override_token) {
4030 var ts = Date.now();
4032 this.displayStatus('notice');
4035 this.confirm_auth = { token: override_token };
4037 var call = function(r, data, duration) {
4038 if (Date.now() >= deadline) {
4039 window.clearTimeout(tt);
4040 UI.prototype.changes.rollback(checked);
4043 else if (r && (r.status === 200 || r.status === 204)) {
4044 document.dispatchEvent(new CustomEvent('uci-applied'));
4046 UI.prototype.changes.setIndicator(0);
4047 UI.prototype.changes.displayStatus('notice',
4048 E('p', _('Configuration changes applied.')));
4050 window.clearTimeout(tt);
4051 window.setTimeout(function() {
4052 //UI.prototype.changes.displayStatus(false);
4053 window.location = window.location.href.split('#')[0];
4054 }, L.env.apply_display * 1000);
4059 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4060 window.setTimeout(function() {
4061 request.request(L.url('admin/uci/confirm'), {
4063 timeout: L.env.apply_timeout * 1000,
4064 query: UI.prototype.changes.confirm_auth
4065 }).then(call, call);
4069 var tick = function() {
4070 var now = Date.now();
4072 UI.prototype.changes.displayStatus('notice spinning',
4073 E('p', _('Applying configuration changes… %ds')
4074 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4076 if (now >= deadline)
4079 tt = window.setTimeout(tick, 1000 - (now - ts));
4085 /* wait a few seconds for the settings to become effective */
4086 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4090 * Apply the staged configuration changes.
4092 * Start applying staged configuration changes and open a modal dialog
4093 * with a progress indication to prevent interaction with the view
4094 * during the apply process. The modal dialog will be automatically
4095 * closed and the current view reloaded once the apply process is
4099 * @memberof LuCI.ui.changes
4100 * @param {boolean} [checked=false]
4101 * Whether to perform a checked (`true`) configuration apply or an
4102 * unchecked (`false`) one.
4104 * In case of a checked apply, the configuration changes must be
4105 * confirmed within a specific time interval, otherwise the device
4106 * will begin to roll back the changes in order to restore the previous
4109 apply: function(checked) {
4110 this.displayStatus('notice spinning',
4111 E('p', _('Starting configuration apply…')));
4113 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4115 query: { sid: L.env.sessionid, token: L.env.token }
4116 }).then(function(r) {
4117 if (r.status === (checked ? 200 : 204)) {
4118 var tok = null; try { tok = r.json(); } catch(e) {}
4119 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4120 UI.prototype.changes.confirm_auth = tok;
4122 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4124 else if (checked && r.status === 204) {
4125 UI.prototype.changes.displayStatus('notice',
4126 E('p', _('There are no changes to apply')));
4128 window.setTimeout(function() {
4129 UI.prototype.changes.displayStatus(false);
4130 }, L.env.apply_display * 1000);
4133 UI.prototype.changes.displayStatus('warning',
4134 E('p', _('Apply request failed with status <code>%h</code>')
4135 .format(r.responseText || r.statusText || r.status)));
4137 window.setTimeout(function() {
4138 UI.prototype.changes.displayStatus(false);
4139 }, L.env.apply_display * 1000);
4145 * Revert the staged configuration changes.
4147 * Start reverting staged configuration changes and open a modal dialog
4148 * with a progress indication to prevent interaction with the view
4149 * during the revert process. The modal dialog will be automatically
4150 * closed and the current view reloaded once the revert process is
4154 * @memberof LuCI.ui.changes
4156 revert: function() {
4157 this.displayStatus('notice spinning',
4158 E('p', _('Reverting configuration…')));
4160 request.request(L.url('admin/uci/revert'), {
4162 query: { sid: L.env.sessionid, token: L.env.token }
4163 }).then(function(r) {
4164 if (r.status === 200) {
4165 document.dispatchEvent(new CustomEvent('uci-reverted'));
4167 UI.prototype.changes.setIndicator(0);
4168 UI.prototype.changes.displayStatus('notice',
4169 E('p', _('Changes have been reverted.')));
4171 window.setTimeout(function() {
4172 //UI.prototype.changes.displayStatus(false);
4173 window.location = window.location.href.split('#')[0];
4174 }, L.env.apply_display * 1000);
4177 UI.prototype.changes.displayStatus('warning',
4178 E('p', _('Revert request failed with status <code>%h</code>')
4179 .format(r.statusText || r.status)));
4181 window.setTimeout(function() {
4182 UI.prototype.changes.displayStatus(false);
4183 }, L.env.apply_display * 1000);
4190 * Add validation constraints to an input element.
4192 * Compile the given type expression and optional validator function into
4193 * a validation function and bind it to the specified input element events.
4195 * @param {Node} field
4196 * The DOM input element node to bind the validation constraints to.
4198 * @param {string} type
4199 * The datatype specification to describe validation constraints.
4200 * Refer to the `LuCI.validation` class documentation for details.
4202 * @param {boolean} [optional=false]
4203 * Specifies whether empty values are allowed (`true`) or not (`false`).
4204 * If an input element is not marked optional it must not be empty,
4205 * otherwise it will be marked as invalid.
4207 * @param {function} [vfunc]
4208 * Specifies a custom validation function which is invoked after the
4209 * other validation constraints are applied. The validation must return
4210 * `true` to accept the passed value. Any other return type is converted
4211 * to a string and treated as validation error message.
4213 * @param {...string} [events=blur, keyup]
4214 * The list of events to bind. Each received event will trigger a field
4215 * validation. If omitted, the `keyup` and `blur` events are bound by
4218 * @returns {function}
4219 * Returns the compiled validator function which can be used to manually
4220 * trigger field validation or to bind it to further events.
4222 * @see LuCI.validation
4224 addValidator: function(field, type, optional, vfunc /*, ... */) {
4228 var events = this.varargs(arguments, 3);
4229 if (events.length == 0)
4230 events.push('blur', 'keyup');
4233 var cbiValidator = validation.create(field, type, optional, vfunc),
4234 validatorFn = cbiValidator.validate.bind(cbiValidator);
4236 for (var i = 0; i < events.length; i++)
4237 field.addEventListener(events[i], validatorFn);
4247 * Create a pre-bound event handler function.
4249 * Generate and bind a function suitable for use in event handlers. The
4250 * generated function automatically disables the event source element
4251 * and adds an active indication to it by adding appropriate CSS classes.
4253 * It will also await any promises returned by the wrapped function and
4254 * re-enable the source element after the promises ran to completion.
4257 * The `this` context to use for the wrapped function.
4259 * @param {function|string} fn
4260 * Specifies the function to wrap. In case of a function value, the
4261 * function is used as-is. If a string is specified instead, it is looked
4262 * up in `ctx` to obtain the function to wrap. In both cases the bound
4263 * function will be invoked with `ctx` as `this` context
4265 * @param {...*} extra_args
4266 * Any further parameter as passed as-is to the bound event handler
4267 * function in the same order as passed to `createHandlerFn()`.
4269 * @returns {function|null}
4270 * Returns the pre-bound handler function which is suitable to be passed
4271 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4272 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4273 * valid function value.
4275 createHandlerFn: function(ctx, fn /*, ... */) {
4276 if (typeof(fn) == 'string')
4279 if (typeof(fn) != 'function')
4282 var arg_offset = arguments.length - 2;
4284 return Function.prototype.bind.apply(function() {
4285 var t = arguments[arg_offset].currentTarget;
4287 t.classList.add('spinning');
4293 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4294 t.classList.remove('spinning');
4297 }, this.varargs(arguments, 2, ctx));
4301 * Load specified view class path and set it up.
4303 * Transforms the given view path into a class name, requires it
4304 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4305 * resulting class instance is a descendant of
4306 * [LuCI.view]{@link LuCI.view}.
4308 * By instantiating the view class, its corresponding contents are
4309 * rendered and included into the view area. Any runtime errors are
4310 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4312 * @param {string} path
4313 * The view path to render.
4315 * @returns {Promise<LuCI.view>}
4316 * Returns a promise resolving to the loaded view instance.
4318 instantiateView: function(path) {
4319 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4321 return L.require(className).then(function(view) {
4322 if (!(view instanceof View))
4323 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4326 }).catch(function(err) {
4327 dom.content(document.querySelector('#view'), null);
4332 AbstractElement: UIElement,
4335 Textfield: UITextfield,
4336 Textarea: UITextarea,
4337 Checkbox: UICheckbox,
4339 Dropdown: UIDropdown,
4340 DynamicList: UIDynamicList,
4341 Combobox: UICombobox,
4342 ComboButton: UIComboButton,
4343 Hiddenfield: UIHiddenfield,
4344 FileUpload: UIFileUpload