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 * Set the current placeholder value of the input widget.
108 * @memberof LuCI.ui.AbstractElement
109 * @param {string|string[]|null} value
110 * The placeholder to set for the input element. Only applicable to text
111 * inputs, not to radio buttons, selects or similar.
113 setPlaceholder: function(value
) {
114 var node
= this.node
? this.node
.querySelector('input,textarea') : null;
116 switch (node
.getAttribute('type') || 'text') {
122 if (value
!= null && value
!= '')
123 node
.setAttribute('placeholder', value
);
125 node
.removeAttribute('placeholder');
131 * Check whether the input value was altered by the user.
134 * @memberof LuCI.ui.AbstractElement
136 * Returns `true` if the input value has been altered by the user or
137 * `false` if it is unchaged. Note that if the user modifies the initial
138 * value and changes it back to the original state, it is still reported
141 isChanged: function() {
142 return (this.node
? this.node
.getAttribute('data-changed') : null) == 'true';
146 * Check whether the current input value is valid.
149 * @memberof LuCI.ui.AbstractElement
151 * Returns `true` if the current input value is valid or `false` if it does
152 * not meet the validation constraints.
154 isValid: function() {
155 return (this.validState
!== false);
159 * Returns the current validation error
162 * @memberof LuCI.ui.AbstractElement
164 * The validation error at this time
166 getValidationError: function() {
167 return this.validationError
|| '';
171 * Force validation of the current input value.
173 * Usually input validation is automatically triggered by various DOM events
174 * bound to the input widget. In some cases it is required though to manually
175 * trigger validation runs, e.g. when programmatically altering values.
178 * @memberof LuCI.ui.AbstractElement
180 triggerValidation: function() {
181 if (typeof(this.vfunc
) != 'function')
184 var wasValid
= this.isValid();
188 return (wasValid
!= this.isValid());
192 * Dispatch a custom (synthetic) event in response to received events.
194 * Sets up event handlers on the given target DOM node for the given event
195 * names that dispatch a custom event of the given type to the widget root
198 * The primary purpose of this function is to set up a series of custom
199 * uniform standard events such as `widget-update`, `validation-success`,
200 * `validation-failure` etc. which are triggered by various different
201 * widget specific native DOM events.
204 * @memberof LuCI.ui.AbstractElement
205 * @param {Node} targetNode
206 * Specifies the DOM node on which the native event listeners should be
209 * @param {string} synevent
210 * The name of the custom event to dispatch to the widget root DOM node.
212 * @param {string[]} events
213 * The native DOM events for which event handlers should be registered.
215 registerEvents: function(targetNode
, synevent
, events
) {
216 var dispatchFn
= L
.bind(function(ev
) {
217 this.node
.dispatchEvent(new CustomEvent(synevent
, { bubbles
: true }));
220 for (var i
= 0; i
< events
.length
; i
++)
221 targetNode
.addEventListener(events
[i
], dispatchFn
);
225 * Setup listeners for native DOM events that may update the widget value.
227 * Sets up event handlers on the given target DOM node for the given event
228 * names which may cause the input value to update, such as `keyup` or
229 * `onclick` events. In contrast to change events, such update events will
230 * trigger input value validation.
233 * @memberof LuCI.ui.AbstractElement
234 * @param {Node} targetNode
235 * Specifies the DOM node on which the event listeners should be registered.
237 * @param {...string} events
238 * The DOM events for which event handlers should be registered.
240 setUpdateEvents: function(targetNode
/*, ... */) {
241 var datatype
= this.options
.datatype
,
242 optional
= this.options
.hasOwnProperty('optional') ? this.options
.optional
: true,
243 validate
= this.options
.validate
,
244 events
= this.varargs(arguments
, 1);
246 this.registerEvents(targetNode
, 'widget-update', events
);
248 if (!datatype
&& !validate
)
251 this.vfunc
= UI
.prototype.addValidator
.apply(UI
.prototype, [
252 targetNode
, datatype
|| 'string',
256 this.node
.addEventListener('validation-success', L
.bind(function(ev
) {
257 this.validState
= true;
258 this.validationError
= '';
261 this.node
.addEventListener('validation-failure', L
.bind(function(ev
) {
262 this.validState
= false;
263 this.validationError
= ev
.detail
.message
;
268 * Setup listeners for native DOM events that may change the widget value.
270 * Sets up event handlers on the given target DOM node for the given event
271 * names which may cause the input value to change completely, such as
272 * `change` events in a select menu. In contrast to update events, such
273 * change events will not trigger input value validation but they may cause
274 * field dependencies to get re-evaluated and will mark the input widget
278 * @memberof LuCI.ui.AbstractElement
279 * @param {Node} targetNode
280 * Specifies the DOM node on which the event listeners should be registered.
282 * @param {...string} events
283 * The DOM events for which event handlers should be registered.
285 setChangeEvents: function(targetNode
/*, ... */) {
286 var tag_changed
= L
.bind(function(ev
) { this.setAttribute('data-changed', true) }, this.node
);
288 for (var i
= 1; i
< arguments
.length
; i
++)
289 targetNode
.addEventListener(arguments
[i
], tag_changed
);
291 this.registerEvents(targetNode
, 'widget-change', this.varargs(arguments
, 1));
295 * Render the widget, setup event listeners and return resulting markup.
298 * @memberof LuCI.ui.AbstractElement
301 * Returns a DOM Node or DocumentFragment containing the rendered
304 render: function() {}
308 * Instantiate a text input widget.
310 * @constructor Textfield
312 * @augments LuCI.ui.AbstractElement
316 * The `Textfield` class implements a standard single line text input field.
318 * UI widget instances are usually not supposed to be created by view code
319 * directly, instead they're implicitely created by `LuCI.form` when
320 * instantiating CBI forms.
322 * This class is automatically instantiated as part of `LuCI.ui`. To use it
323 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
324 * external JavaScript, use `L.require("ui").then(...)` and access the
325 * `Textfield` property of the class instance value.
327 * @param {string} [value=null]
328 * The initial input value.
330 * @param {LuCI.ui.Textfield.InitOptions} [options]
331 * Object describing the widget specific options to initialize the input.
333 var UITextfield
= UIElement
.extend(/** @lends LuCI.ui.Textfield.prototype */ {
335 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
336 * the following properties are recognized:
338 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
339 * @memberof LuCI.ui.Textfield
341 * @property {boolean} [password=false]
342 * Specifies whether the input should be rendered as concealed password field.
344 * @property {boolean} [readonly=false]
345 * Specifies whether the input widget should be rendered readonly.
347 * @property {number} [maxlength]
348 * Specifies the HTML `maxlength` attribute to set on the corresponding
349 * `<input>` element. Note that this a legacy property that exists for
350 * compatibility reasons. It is usually better to `maxlength(N)` validation
353 * @property {string} [placeholder]
354 * Specifies the HTML `placeholder` attribute which is displayed when the
355 * corresponding `<input>` element is empty.
357 __init__: function(value
, options
) {
359 this.options
= Object
.assign({
367 var frameEl
= E('div', { 'id': this.options
.id
});
368 var inputEl
= E('input', {
369 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
370 'name': this.options
.name
,
372 'class': this.options
.password
? 'cbi-input-password' : 'cbi-input-text',
373 'readonly': this.options
.readonly
? '' : null,
374 'disabled': this.options
.disabled
? '' : null,
375 'maxlength': this.options
.maxlength
,
376 'placeholder': this.options
.placeholder
,
377 'autocomplete': this.options
.password
? 'new-password' : null,
381 if (this.options
.password
) {
382 frameEl
.appendChild(E('div', { 'class': 'control-group' }, [
385 'class': 'cbi-button cbi-button-neutral',
386 'title': _('Reveal/hide password'),
387 'aria-label': _('Reveal/hide password'),
388 'click': function(ev
) {
389 var e
= this.previousElementSibling
;
390 e
.type
= (e
.type
=== 'password') ? 'text' : 'password';
396 window
.requestAnimationFrame(function() { inputEl
.type
= 'password' });
399 frameEl
.appendChild(inputEl
);
402 return this.bind(frameEl
);
406 bind: function(frameEl
) {
407 var inputEl
= frameEl
.querySelector('input');
411 this.setUpdateEvents(inputEl
, 'keyup', 'blur');
412 this.setChangeEvents(inputEl
, 'change');
414 dom
.bindClassInstance(frameEl
, this);
420 getValue: function() {
421 var inputEl
= this.node
.querySelector('input');
422 return inputEl
.value
;
426 setValue: function(value
) {
427 var inputEl
= this.node
.querySelector('input');
428 inputEl
.value
= value
;
433 * Instantiate a textarea widget.
435 * @constructor Textarea
437 * @augments LuCI.ui.AbstractElement
441 * The `Textarea` class implements a multiline text area input field.
443 * UI widget instances are usually not supposed to be created by view code
444 * directly, instead they're implicitely created by `LuCI.form` when
445 * instantiating CBI forms.
447 * This class is automatically instantiated as part of `LuCI.ui`. To use it
448 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
449 * external JavaScript, use `L.require("ui").then(...)` and access the
450 * `Textarea` property of the class instance value.
452 * @param {string} [value=null]
453 * The initial input value.
455 * @param {LuCI.ui.Textarea.InitOptions} [options]
456 * Object describing the widget specific options to initialize the input.
458 var UITextarea
= UIElement
.extend(/** @lends LuCI.ui.Textarea.prototype */ {
460 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
461 * the following properties are recognized:
463 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
464 * @memberof LuCI.ui.Textarea
466 * @property {boolean} [readonly=false]
467 * Specifies whether the input widget should be rendered readonly.
469 * @property {string} [placeholder]
470 * Specifies the HTML `placeholder` attribute which is displayed when the
471 * corresponding `<textarea>` element is empty.
473 * @property {boolean} [monospace=false]
474 * Specifies whether a monospace font should be forced for the textarea
477 * @property {number} [cols]
478 * Specifies the HTML `cols` attribute to set on the corresponding
479 * `<textarea>` element.
481 * @property {number} [rows]
482 * Specifies the HTML `rows` attribute to set on the corresponding
483 * `<textarea>` element.
485 * @property {boolean} [wrap=false]
486 * Specifies whether the HTML `wrap` attribute should be set.
488 __init__: function(value
, options
) {
490 this.options
= Object
.assign({
500 var style
= !this.options
.cols
? 'width:100%' : null,
501 frameEl
= E('div', { 'id': this.options
.id
, 'style': style
}),
502 value
= (this.value
!= null) ? String(this.value
) : '';
504 frameEl
.appendChild(E('textarea', {
505 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
506 'name': this.options
.name
,
507 'class': 'cbi-input-textarea',
508 'readonly': this.options
.readonly
? '' : null,
509 'disabled': this.options
.disabled
? '' : null,
510 'placeholder': this.options
.placeholder
,
512 'cols': this.options
.cols
,
513 'rows': this.options
.rows
,
514 'wrap': this.options
.wrap
? '' : null
517 if (this.options
.monospace
)
518 frameEl
.firstElementChild
.style
.fontFamily
= 'monospace';
520 return this.bind(frameEl
);
524 bind: function(frameEl
) {
525 var inputEl
= frameEl
.firstElementChild
;
529 this.setUpdateEvents(inputEl
, 'keyup', 'blur');
530 this.setChangeEvents(inputEl
, 'change');
532 dom
.bindClassInstance(frameEl
, this);
538 getValue: function() {
539 return this.node
.firstElementChild
.value
;
543 setValue: function(value
) {
544 this.node
.firstElementChild
.value
= value
;
549 * Instantiate a checkbox widget.
551 * @constructor Checkbox
553 * @augments LuCI.ui.AbstractElement
557 * The `Checkbox` class implements a simple checkbox input field.
559 * UI widget instances are usually not supposed to be created by view code
560 * directly, instead they're implicitely created by `LuCI.form` when
561 * instantiating CBI forms.
563 * This class is automatically instantiated as part of `LuCI.ui`. To use it
564 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
565 * external JavaScript, use `L.require("ui").then(...)` and access the
566 * `Checkbox` property of the class instance value.
568 * @param {string} [value=null]
569 * The initial input value.
571 * @param {LuCI.ui.Checkbox.InitOptions} [options]
572 * Object describing the widget specific options to initialize the input.
574 var UICheckbox
= UIElement
.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
576 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
577 * the following properties are recognized:
579 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
580 * @memberof LuCI.ui.Checkbox
582 * @property {string} [value_enabled=1]
583 * Specifies the value corresponding to a checked checkbox.
585 * @property {string} [value_disabled=0]
586 * Specifies the value corresponding to an unchecked checkbox.
588 * @property {string} [hiddenname]
589 * Specifies the HTML `name` attribute of the hidden input backing the
590 * checkbox. This is a legacy property existing for compatibility reasons,
591 * it is required for HTML based form submissions.
593 __init__: function(value
, options
) {
595 this.options
= Object
.assign({
603 var id
= 'cb%08x'.format(Math
.random() * 0xffffffff);
604 var frameEl
= E('div', {
605 'id': this.options
.id
,
606 'class': 'cbi-checkbox'
609 if (this.options
.hiddenname
)
610 frameEl
.appendChild(E('input', {
612 'name': this.options
.hiddenname
,
616 frameEl
.appendChild(E('input', {
618 'name': this.options
.name
,
620 'value': this.options
.value_enabled
,
621 'checked': (this.value
== this.options
.value_enabled
) ? '' : null,
622 'disabled': this.options
.disabled
? '' : null,
623 'data-widget-id': this.options
.id
? 'widget.' + this.options
.id
: null
626 frameEl
.appendChild(E('label', { 'for': id
}));
628 if (this.options
.tooltip
!= null) {
631 if (this.options
.tooltipicon
!= null)
632 icon
= this.options
.tooltipicon
;
635 E('label', { 'class': 'cbi-tooltip-container' },[
637 E('div', { 'class': 'cbi-tooltip' },
644 return this.bind(frameEl
);
648 bind: function(frameEl
) {
651 var input
= frameEl
.querySelector('input[type="checkbox"]');
652 this.setUpdateEvents(input
, 'click', 'blur');
653 this.setChangeEvents(input
, 'change');
655 dom
.bindClassInstance(frameEl
, this);
661 * Test whether the checkbox is currently checked.
664 * @memberof LuCI.ui.Checkbox
666 * Returns `true` when the checkbox is currently checked, otherwise `false`.
668 isChecked: function() {
669 return this.node
.querySelector('input[type="checkbox"]').checked
;
673 getValue: function() {
674 return this.isChecked()
675 ? this.options
.value_enabled
676 : this.options
.value_disabled
;
680 setValue: function(value
) {
681 this.node
.querySelector('input[type="checkbox"]').checked
= (value
== this.options
.value_enabled
);
686 * Instantiate a select dropdown or checkbox/radiobutton group.
688 * @constructor Select
690 * @augments LuCI.ui.AbstractElement
694 * The `Select` class implements either a traditional HTML `<select>` element
695 * or a group of checkboxes or radio buttons, depending on whether multiple
696 * values are enabled or not.
698 * UI widget instances are usually not supposed to be created by view code
699 * directly, instead they're implicitely created by `LuCI.form` when
700 * instantiating CBI forms.
702 * This class is automatically instantiated as part of `LuCI.ui`. To use it
703 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
704 * external JavaScript, use `L.require("ui").then(...)` and access the
705 * `Select` property of the class instance value.
707 * @param {string|string[]} [value=null]
708 * The initial input value(s).
710 * @param {Object<string, string>} choices
711 * Object containing the selectable choices of the widget. The object keys
712 * serve as values for the different choices while the values are used as
715 * @param {LuCI.ui.Select.InitOptions} [options]
716 * Object describing the widget specific options to initialize the inputs.
718 var UISelect
= UIElement
.extend(/** @lends LuCI.ui.Select.prototype */ {
720 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
721 * the following properties are recognized:
723 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
724 * @memberof LuCI.ui.Select
726 * @property {boolean} [multiple=false]
727 * Specifies whether multiple choice values may be selected.
729 * @property {string} [widget=select]
730 * Specifies the kind of widget to render. May be either `select` or
731 * `individual`. When set to `select` an HTML `<select>` element will be
732 * used, otherwise a group of checkbox or radio button elements is created,
733 * depending on the value of the `multiple` option.
735 * @property {string} [orientation=horizontal]
736 * Specifies whether checkbox / radio button groups should be rendered
737 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
740 * @property {boolean|string[]} [sort=false]
741 * Specifies if and how to sort choice values. If set to `true`, the choice
742 * values will be sorted alphabetically. If set to an array of strings, the
743 * choice sort order is derived from the array.
745 * @property {number} [size]
746 * Specifies the HTML `size` attribute to set on the `<select>` element.
747 * Only applicable to the `select` widget type.
749 * @property {string} [placeholder=-- Please choose --]
750 * Specifies a placeholder text which is displayed when no choice is
751 * selected yet. Only applicable to the `select` widget type.
753 __init__: function(value
, choices
, options
) {
754 if (!L
.isObject(choices
))
757 if (!Array
.isArray(value
))
758 value
= (value
!= null && value
!= '') ? [ value
] : [];
760 if (!options
.multiple
&& value
.length
> 1)
764 this.choices
= choices
;
765 this.options
= Object
.assign({
768 orientation
: 'horizontal'
771 if (this.choices
.hasOwnProperty(''))
772 this.options
.optional
= true;
777 var frameEl
= E('div', { 'id': this.options
.id
}),
778 keys
= Object
.keys(this.choices
);
780 if (this.options
.sort
=== true)
781 keys
.sort(L
.naturalCompare
);
782 else if (Array
.isArray(this.options
.sort
))
783 keys
= this.options
.sort
;
785 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
786 frameEl
.appendChild(E('select', {
787 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
788 'name': this.options
.name
,
789 'size': this.options
.size
,
790 'class': 'cbi-input-select',
791 'multiple': this.options
.multiple
? '' : null,
792 'disabled': this.options
.disabled
? '' : null
795 if (this.options
.optional
)
796 frameEl
.lastChild
.appendChild(E('option', {
798 'selected': (this.values
.length
== 0 || this.values
[0] == '') ? '' : null
799 }, [ this.choices
[''] || this.options
.placeholder
|| _('-- Please choose --') ]));
801 for (var i
= 0; i
< keys
.length
; i
++) {
802 if (keys
[i
] == null || keys
[i
] == '')
805 frameEl
.lastChild
.appendChild(E('option', {
807 'selected': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null
808 }, [ this.choices
[keys
[i
]] || keys
[i
] ]));
812 var brEl
= (this.options
.orientation
=== 'horizontal') ? document
.createTextNode(' \xa0 ') : E('br');
814 for (var i
= 0; i
< keys
.length
; i
++) {
815 frameEl
.appendChild(E('span', {
816 'class': 'cbi-%s'.format(this.options
.multiple
? 'checkbox' : 'radio')
819 'id': this.options
.id
? 'widget.%s.%d'.format(this.options
.id
, i
) : null,
820 'name': this.options
.id
|| this.options
.name
,
821 'type': this.options
.multiple
? 'checkbox' : 'radio',
822 'class': this.options
.multiple
? 'cbi-input-checkbox' : 'cbi-input-radio',
824 'checked': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null,
825 'disabled': this.options
.disabled
? '' : null
827 E('label', { 'for': this.options
.id
? 'widget.%s.%d'.format(this.options
.id
, i
) : null }),
829 'click': function(ev
) {
830 ev
.currentTarget
.previousElementSibling
.previousElementSibling
.click();
832 }, [ this.choices
[keys
[i
]] || keys
[i
] ])
835 frameEl
.appendChild(brEl
.cloneNode());
839 return this.bind(frameEl
);
843 bind: function(frameEl
) {
846 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
847 this.setUpdateEvents(frameEl
.firstChild
, 'change', 'click', 'blur');
848 this.setChangeEvents(frameEl
.firstChild
, 'change');
851 var radioEls
= frameEl
.querySelectorAll('input[type="radio"]');
852 for (var i
= 0; i
< radioEls
.length
; i
++) {
853 this.setUpdateEvents(radioEls
[i
], 'change', 'click', 'blur');
854 this.setChangeEvents(radioEls
[i
], 'change', 'click', 'blur');
858 dom
.bindClassInstance(frameEl
, this);
864 getValue: function() {
865 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox')
866 return this.node
.firstChild
.value
;
868 var radioEls
= this.node
.querySelectorAll('input[type="radio"]');
869 for (var i
= 0; i
< radioEls
.length
; i
++)
870 if (radioEls
[i
].checked
)
871 return radioEls
[i
].value
;
877 setValue: function(value
) {
878 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
882 for (var i
= 0; i
< this.node
.firstChild
.options
.length
; i
++)
883 this.node
.firstChild
.options
[i
].selected
= (this.node
.firstChild
.options
[i
].value
== value
);
888 var radioEls
= frameEl
.querySelectorAll('input[type="radio"]');
889 for (var i
= 0; i
< radioEls
.length
; i
++)
890 radioEls
[i
].checked
= (radioEls
[i
].value
== value
);
895 * Instantiate a rich dropdown choice widget.
897 * @constructor Dropdown
899 * @augments LuCI.ui.AbstractElement
903 * The `Dropdown` class implements a rich, stylable dropdown menu which
904 * supports non-text choice labels.
906 * UI widget instances are usually not supposed to be created by view code
907 * directly, instead they're implicitely created by `LuCI.form` when
908 * instantiating CBI forms.
910 * This class is automatically instantiated as part of `LuCI.ui`. To use it
911 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
912 * external JavaScript, use `L.require("ui").then(...)` and access the
913 * `Dropdown` property of the class instance value.
915 * @param {string|string[]} [value=null]
916 * The initial input value(s).
918 * @param {Object<string, *>} choices
919 * Object containing the selectable choices of the widget. The object keys
920 * serve as values for the different choices while the values are used as
923 * @param {LuCI.ui.Dropdown.InitOptions} [options]
924 * Object describing the widget specific options to initialize the dropdown.
926 var UIDropdown
= UIElement
.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
928 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
929 * the following properties are recognized:
931 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
932 * @memberof LuCI.ui.Dropdown
934 * @property {boolean} [optional=true]
935 * Specifies whether the dropdown selection is optional. In contrast to
936 * other widgets, the `optional` constraint of dropdowns works differently;
937 * instead of marking the widget invalid on empty values when set to `false`,
938 * the user is not allowed to deselect all choices.
940 * For single value dropdowns that means that no empty "please select"
941 * choice is offered and for multi value dropdowns, the last selected choice
942 * may not be deselected without selecting another choice first.
944 * @property {boolean} [multiple]
945 * Specifies whether multiple choice values may be selected. It defaults
946 * to `true` when an array is passed as input value to the constructor.
948 * @property {boolean|string[]} [sort=false]
949 * Specifies if and how to sort choice values. If set to `true`, the choice
950 * values will be sorted alphabetically. If set to an array of strings, the
951 * choice sort order is derived from the array.
953 * @property {string} [select_placeholder=-- Please choose --]
954 * Specifies a placeholder text which is displayed when no choice is
957 * @property {string} [custom_placeholder=-- custom --]
958 * Specifies a placeholder text which is displayed in the text input
959 * field allowing to enter custom choice values. Only applicable if the
960 * `create` option is set to `true`.
962 * @property {boolean} [create=false]
963 * Specifies whether custom choices may be entered into the dropdown
966 * @property {string} [create_query=.create-item-input]
967 * Specifies a CSS selector expression used to find the input element
968 * which is used to enter custom choice values. This should not normally
969 * be used except by widgets derived from the Dropdown class.
971 * @property {string} [create_template=script[type="item-template"]]
972 * Specifies a CSS selector expression used to find an HTML element
973 * serving as template for newly added custom choice values.
975 * Any `{{value}}` placeholder string within the template elements text
976 * content will be replaced by the user supplied choice value, the
977 * resulting string is parsed as HTML and appended to the end of the
978 * choice list. The template markup may specify one HTML element with a
979 * `data-label-placeholder` attribute which is replaced by a matching
980 * label value from the `choices` object or with the user supplied value
981 * itself in case `choices` contains no matching choice label.
983 * If the template element is not found or if no `create_template` selector
984 * expression is specified, the default markup for newly created elements is
985 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
987 * @property {string} [create_markup]
988 * This property allows specifying the markup for custom choices directly
989 * instead of referring to a template element through CSS selectors.
991 * Apart from that it works exactly like `create_template`.
993 * @property {number} [display_items=3]
994 * Specifies the maximum amount of choice labels that should be shown in
995 * collapsed dropdown state before further selected choices are cut off.
997 * Only applicable when `multiple` is `true`.
999 * @property {number} [dropdown_items=-1]
1000 * Specifies the maximum amount of choices that should be shown when the
1001 * dropdown is open. If the amount of available choices exceeds this number,
1002 * the dropdown area must be scrolled to reach further items.
1004 * If set to `-1`, the dropdown menu will attempt to show all choice values
1005 * and only resort to scrolling if the amount of choices exceeds the available
1006 * screen space above and below the dropdown widget.
1008 * @property {string} [placeholder]
1009 * This property serves as a shortcut to set both `select_placeholder` and
1010 * `custom_placeholder`. Either of these properties will fallback to
1011 * `placeholder` if not specified.
1013 * @property {boolean} [readonly=false]
1014 * Specifies whether the custom choice input field should be rendered
1015 * readonly. Only applicable when `create` is `true`.
1017 * @property {number} [maxlength]
1018 * Specifies the HTML `maxlength` attribute to set on the custom choice
1019 * `<input>` element. Note that this a legacy property that exists for
1020 * compatibility reasons. It is usually better to `maxlength(N)` validation
1021 * expression. Only applicable when `create` is `true`.
1023 __init__: function(value
, choices
, options
) {
1024 if (typeof(choices
) != 'object')
1027 if (!Array
.isArray(value
))
1028 this.values
= (value
!= null && value
!= '') ? [ value
] : [];
1030 this.values
= value
;
1032 this.choices
= choices
;
1033 this.options
= Object
.assign({
1035 multiple
: Array
.isArray(value
),
1037 select_placeholder
: _('-- Please choose --'),
1038 custom_placeholder
: _('-- custom --'),
1042 create_query
: '.create-item-input',
1043 create_template
: 'script[type="item-template"]'
1048 render: function() {
1050 'id': this.options
.id
,
1051 'class': 'cbi-dropdown',
1052 'multiple': this.options
.multiple
? '' : null,
1053 'optional': this.options
.optional
? '' : null,
1054 'disabled': this.options
.disabled
? '' : null,
1058 var keys
= Object
.keys(this.choices
);
1060 if (this.options
.sort
=== true)
1061 keys
.sort(L
.naturalCompare
);
1062 else if (Array
.isArray(this.options
.sort
))
1063 keys
= this.options
.sort
;
1065 if (this.options
.create
)
1066 for (var i
= 0; i
< this.values
.length
; i
++)
1067 if (!this.choices
.hasOwnProperty(this.values
[i
]))
1068 keys
.push(this.values
[i
]);
1070 for (var i
= 0; i
< keys
.length
; i
++) {
1071 var label
= this.choices
[keys
[i
]];
1073 if (dom
.elem(label
))
1074 label
= label
.cloneNode(true);
1076 sb
.lastElementChild
.appendChild(E('li', {
1077 'data-value': keys
[i
],
1078 'selected': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null
1079 }, [ label
|| keys
[i
] ]));
1082 if (this.options
.create
) {
1083 var createEl
= E('input', {
1085 'class': 'create-item-input',
1086 'readonly': this.options
.readonly
? '' : null,
1087 'maxlength': this.options
.maxlength
,
1088 'placeholder': this.options
.custom_placeholder
|| this.options
.placeholder
1091 if (this.options
.datatype
|| this.options
.validate
)
1092 UI
.prototype.addValidator(createEl
, this.options
.datatype
|| 'string',
1093 true, this.options
.validate
, 'blur', 'keyup');
1095 sb
.lastElementChild
.appendChild(E('li', { 'data-value': '-' }, createEl
));
1098 if (this.options
.create_markup
)
1099 sb
.appendChild(E('script', { type
: 'item-template' },
1100 this.options
.create_markup
));
1102 return this.bind(sb
);
1106 bind: function(sb
) {
1107 var o
= this.options
;
1109 o
.multiple
= sb
.hasAttribute('multiple');
1110 o
.optional
= sb
.hasAttribute('optional');
1111 o
.placeholder
= sb
.getAttribute('placeholder') || o
.placeholder
;
1112 o
.display_items
= parseInt(sb
.getAttribute('display-items') || o
.display_items
);
1113 o
.dropdown_items
= parseInt(sb
.getAttribute('dropdown-items') || o
.dropdown_items
);
1114 o
.create_query
= sb
.getAttribute('item-create') || o
.create_query
;
1115 o
.create_template
= sb
.getAttribute('item-template') || o
.create_template
;
1117 var ul
= sb
.querySelector('ul'),
1118 more
= sb
.appendChild(E('span', { class: 'more', tabindex
: -1 }, '···')),
1119 open
= sb
.appendChild(E('span', { class: 'open', tabindex
: -1 }, '▾')),
1120 canary
= sb
.appendChild(E('div')),
1121 create
= sb
.querySelector(this.options
.create_query
),
1122 ndisplay
= this.options
.display_items
,
1125 if (this.options
.multiple
) {
1126 var items
= ul
.querySelectorAll('li');
1128 for (var i
= 0; i
< items
.length
; i
++) {
1129 this.transformItem(sb
, items
[i
]);
1131 if (items
[i
].hasAttribute('selected') && ndisplay
-- > 0)
1132 items
[i
].setAttribute('display', n
++);
1136 if (this.options
.optional
&& !ul
.querySelector('li[data-value=""]')) {
1137 var placeholder
= E('li', { placeholder
: '' },
1138 this.options
.select_placeholder
|| this.options
.placeholder
);
1141 ? ul
.insertBefore(placeholder
, ul
.firstChild
)
1142 : ul
.appendChild(placeholder
);
1145 var items
= ul
.querySelectorAll('li'),
1146 sel
= sb
.querySelectorAll('[selected]');
1148 sel
.forEach(function(s
) {
1149 s
.removeAttribute('selected');
1152 var s
= sel
[0] || items
[0];
1154 s
.setAttribute('selected', '');
1155 s
.setAttribute('display', n
++);
1161 this.saveValues(sb
, ul
);
1163 ul
.setAttribute('tabindex', -1);
1164 sb
.setAttribute('tabindex', 0);
1167 sb
.setAttribute('more', '')
1169 sb
.removeAttribute('more');
1171 if (ndisplay
== this.options
.display_items
)
1172 sb
.setAttribute('empty', '')
1174 sb
.removeAttribute('empty');
1176 dom
.content(more
, (ndisplay
== this.options
.display_items
)
1177 ? (this.options
.select_placeholder
|| this.options
.placeholder
) : '···');
1180 sb
.addEventListener('click', this.handleClick
.bind(this));
1181 sb
.addEventListener('keydown', this.handleKeydown
.bind(this));
1182 sb
.addEventListener('cbi-dropdown-close', this.handleDropdownClose
.bind(this));
1183 sb
.addEventListener('cbi-dropdown-select', this.handleDropdownSelect
.bind(this));
1185 if ('ontouchstart' in window
) {
1186 sb
.addEventListener('touchstart', function(ev
) { ev
.stopPropagation(); });
1187 window
.addEventListener('touchstart', this.closeAllDropdowns
);
1190 sb
.addEventListener('focus', this.handleFocus
.bind(this));
1192 canary
.addEventListener('focus', this.handleCanaryFocus
.bind(this));
1194 window
.addEventListener('click', this.closeAllDropdowns
);
1198 create
.addEventListener('keydown', this.handleCreateKeydown
.bind(this));
1199 create
.addEventListener('focus', this.handleCreateFocus
.bind(this));
1200 create
.addEventListener('blur', this.handleCreateBlur
.bind(this));
1202 var li
= findParent(create
, 'li');
1204 li
.setAttribute('unselectable', '');
1205 li
.addEventListener('click', this.handleCreateClick
.bind(this));
1210 this.setUpdateEvents(sb
, 'cbi-dropdown-open', 'cbi-dropdown-close');
1211 this.setChangeEvents(sb
, 'cbi-dropdown-change', 'cbi-dropdown-close');
1213 dom
.bindClassInstance(sb
, this);
1219 getScrollParent: function(element
) {
1220 var parent
= element
,
1221 style
= getComputedStyle(element
),
1222 excludeStaticParent
= (style
.position
=== 'absolute');
1224 if (style
.position
=== 'fixed')
1225 return document
.body
;
1227 while ((parent
= parent
.parentElement
) != null) {
1228 style
= getComputedStyle(parent
);
1230 if (excludeStaticParent
&& style
.position
=== 'static')
1233 if (/(auto|scroll)/.test(style
.overflow
+ style
.overflowY
+ style
.overflowX
))
1237 return document
.body
;
1241 openDropdown: function(sb
) {
1242 var st
= window
.getComputedStyle(sb
, null),
1243 ul
= sb
.querySelector('ul'),
1244 li
= ul
.querySelectorAll('li'),
1245 fl
= findParent(sb
, '.cbi-value-field'),
1246 sel
= ul
.querySelector('[selected]'),
1247 rect
= sb
.getBoundingClientRect(),
1248 items
= Math
.min(this.options
.dropdown_items
, li
.length
),
1249 scrollParent
= this.getScrollParent(sb
);
1251 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1252 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1255 sb
.setAttribute('open', '');
1257 var pv
= ul
.cloneNode(true);
1258 pv
.classList
.add('preview');
1261 fl
.classList
.add('cbi-dropdown-open');
1263 if ('ontouchstart' in window
) {
1264 var vpWidth
= Math
.max(document
.documentElement
.clientWidth
, window
.innerWidth
|| 0),
1265 vpHeight
= Math
.max(document
.documentElement
.clientHeight
, window
.innerHeight
|| 0),
1268 ul
.style
.top
= sb
.offsetHeight
+ 'px';
1269 ul
.style
.left
= -rect
.left
+ 'px';
1270 ul
.style
.right
= (rect
.right
- vpWidth
) + 'px';
1271 ul
.style
.maxHeight
= (vpHeight
* 0.5) + 'px';
1272 ul
.style
.WebkitOverflowScrolling
= 'touch';
1274 var scrollFrom
= scrollParent
.scrollTop
,
1275 scrollTo
= scrollFrom
+ rect
.top
- vpHeight
* 0.5;
1277 var scrollStep = function(timestamp
) {
1280 ul
.scrollTop
= sel
? Math
.max(sel
.offsetTop
- sel
.offsetHeight
, 0) : 0;
1283 var duration
= Math
.max(timestamp
- start
, 1);
1284 if (duration
< 100) {
1285 scrollParent
.scrollTop
= scrollFrom
+ (scrollTo
- scrollFrom
) * (duration
/ 100);
1286 window
.requestAnimationFrame(scrollStep
);
1289 scrollParent
.scrollTop
= scrollTo
;
1293 window
.requestAnimationFrame(scrollStep
);
1296 ul
.style
.maxHeight
= '1px';
1297 ul
.style
.top
= ul
.style
.bottom
= '';
1299 window
.requestAnimationFrame(function() {
1300 var containerRect
= scrollParent
.getBoundingClientRect(),
1301 itemHeight
= li
[Math
.max(0, li
.length
- 2)].getBoundingClientRect().height
,
1303 spaceAbove
= rect
.top
- containerRect
.top
,
1304 spaceBelow
= containerRect
.bottom
- rect
.bottom
;
1306 for (var i
= 0; i
< (items
== -1 ? li
.length
: items
); i
++)
1307 fullHeight
+= li
[i
].getBoundingClientRect().height
;
1309 if (fullHeight
<= spaceBelow
) {
1310 ul
.style
.top
= rect
.height
+ 'px';
1311 ul
.style
.maxHeight
= spaceBelow
+ 'px';
1313 else if (fullHeight
<= spaceAbove
) {
1314 ul
.style
.bottom
= rect
.height
+ 'px';
1315 ul
.style
.maxHeight
= spaceAbove
+ 'px';
1317 else if (spaceBelow
>= spaceAbove
) {
1318 ul
.style
.top
= rect
.height
+ 'px';
1319 ul
.style
.maxHeight
= (spaceBelow
- (spaceBelow
% itemHeight
)) + 'px';
1322 ul
.style
.bottom
= rect
.height
+ 'px';
1323 ul
.style
.maxHeight
= (spaceAbove
- (spaceAbove
% itemHeight
)) + 'px';
1326 ul
.scrollTop
= sel
? Math
.max(sel
.offsetTop
- sel
.offsetHeight
, 0) : 0;
1330 var cboxes
= ul
.querySelectorAll('[selected] input[type="checkbox"]');
1331 for (var i
= 0; i
< cboxes
.length
; i
++) {
1332 cboxes
[i
].checked
= true;
1333 cboxes
[i
].disabled
= (cboxes
.length
== 1 && !this.options
.optional
);
1336 ul
.classList
.add('dropdown');
1338 sb
.insertBefore(pv
, ul
.nextElementSibling
);
1340 li
.forEach(function(l
) {
1341 if (!l
.hasAttribute('unselectable'))
1342 l
.setAttribute('tabindex', 0);
1345 sb
.lastElementChild
.setAttribute('tabindex', 0);
1347 var focusFn
= L
.bind(function(el
) {
1348 this.setFocus(sb
, el
, true);
1349 ul
.removeEventListener('transitionend', focusFn
);
1350 }, this, sel
|| li
[0]);
1352 ul
.addEventListener('transitionend', focusFn
);
1356 closeDropdown: function(sb
, no_focus
) {
1357 if (!sb
.hasAttribute('open'))
1360 var pv
= sb
.querySelector('ul.preview'),
1361 ul
= sb
.querySelector('ul.dropdown'),
1362 li
= ul
.querySelectorAll('li'),
1363 fl
= findParent(sb
, '.cbi-value-field');
1365 li
.forEach(function(l
) { l
.removeAttribute('tabindex'); });
1366 sb
.lastElementChild
.removeAttribute('tabindex');
1369 sb
.removeAttribute('open');
1370 sb
.style
.width
= sb
.style
.height
= '';
1372 ul
.classList
.remove('dropdown');
1373 ul
.style
.top
= ul
.style
.bottom
= ul
.style
.maxHeight
= '';
1376 fl
.classList
.remove('cbi-dropdown-open');
1379 this.setFocus(sb
, sb
);
1381 this.saveValues(sb
, ul
);
1385 toggleItem: function(sb
, li
, force_state
) {
1386 var ul
= li
.parentNode
;
1388 if (li
.hasAttribute('unselectable'))
1391 if (this.options
.multiple
) {
1392 var cbox
= li
.querySelector('input[type="checkbox"]'),
1393 items
= li
.parentNode
.querySelectorAll('li'),
1394 label
= sb
.querySelector('ul.preview'),
1395 sel
= li
.parentNode
.querySelectorAll('[selected]').length
,
1396 more
= sb
.querySelector('.more'),
1397 ndisplay
= this.options
.display_items
,
1400 if (li
.hasAttribute('selected')) {
1401 if (force_state
!== true) {
1402 if (sel
> 1 || this.options
.optional
) {
1403 li
.removeAttribute('selected');
1404 cbox
.checked
= cbox
.disabled
= false;
1408 cbox
.disabled
= true;
1413 if (force_state
!== false) {
1414 li
.setAttribute('selected', '');
1415 cbox
.checked
= true;
1416 cbox
.disabled
= false;
1421 while (label
&& label
.firstElementChild
)
1422 label
.removeChild(label
.firstElementChild
);
1424 for (var i
= 0; i
< items
.length
; i
++) {
1425 items
[i
].removeAttribute('display');
1426 if (items
[i
].hasAttribute('selected')) {
1427 if (ndisplay
-- > 0) {
1428 items
[i
].setAttribute('display', n
++);
1430 label
.appendChild(items
[i
].cloneNode(true));
1432 var c
= items
[i
].querySelector('input[type="checkbox"]');
1434 c
.disabled
= (sel
== 1 && !this.options
.optional
);
1439 sb
.setAttribute('more', '');
1441 sb
.removeAttribute('more');
1443 if (ndisplay
=== this.options
.display_items
)
1444 sb
.setAttribute('empty', '');
1446 sb
.removeAttribute('empty');
1448 dom
.content(more
, (ndisplay
=== this.options
.display_items
)
1449 ? (this.options
.select_placeholder
|| this.options
.placeholder
) : '···');
1452 var sel
= li
.parentNode
.querySelector('[selected]');
1454 sel
.removeAttribute('display');
1455 sel
.removeAttribute('selected');
1458 li
.setAttribute('display', 0);
1459 li
.setAttribute('selected', '');
1461 this.closeDropdown(sb
, true);
1464 this.saveValues(sb
, ul
);
1468 transformItem: function(sb
, li
) {
1469 var cbox
= E('form', {}, E('input', { type
: 'checkbox', tabindex
: -1, onclick
: 'event.preventDefault()' })),
1472 while (li
.firstChild
)
1473 label
.appendChild(li
.firstChild
);
1475 li
.appendChild(cbox
);
1476 li
.appendChild(label
);
1480 saveValues: function(sb
, ul
) {
1481 var sel
= ul
.querySelectorAll('li[selected]'),
1482 div
= sb
.lastElementChild
,
1483 name
= this.options
.name
,
1487 while (div
.lastElementChild
)
1488 div
.removeChild(div
.lastElementChild
);
1490 sel
.forEach(function (s
) {
1491 if (s
.hasAttribute('placeholder'))
1496 value
: s
.hasAttribute('data-value') ? s
.getAttribute('data-value') : s
.innerText
,
1500 div
.appendChild(E('input', {
1508 strval
+= strval
.length
? ' ' + v
.value
: v
.value
;
1516 if (this.options
.multiple
)
1517 detail
.values
= values
;
1519 detail
.value
= values
.length
? values
[0] : null;
1523 sb
.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1530 setValues: function(sb
, values
) {
1531 var ul
= sb
.querySelector('ul');
1533 if (this.options
.create
) {
1534 for (var value
in values
) {
1535 this.createItems(sb
, value
);
1537 if (!this.options
.multiple
)
1542 if (this.options
.multiple
) {
1543 var lis
= ul
.querySelectorAll('li[data-value]');
1544 for (var i
= 0; i
< lis
.length
; i
++) {
1545 var value
= lis
[i
].getAttribute('data-value');
1546 if (values
=== null || !(value
in values
))
1547 this.toggleItem(sb
, lis
[i
], false);
1549 this.toggleItem(sb
, lis
[i
], true);
1553 var ph
= ul
.querySelector('li[placeholder]');
1555 this.toggleItem(sb
, ph
);
1557 var lis
= ul
.querySelectorAll('li[data-value]');
1558 for (var i
= 0; i
< lis
.length
; i
++) {
1559 var value
= lis
[i
].getAttribute('data-value');
1560 if (values
!== null && (value
in values
))
1561 this.toggleItem(sb
, lis
[i
]);
1567 setFocus: function(sb
, elem
, scroll
) {
1568 if (sb
.hasAttribute('locked-in'))
1571 sb
.querySelectorAll('.focus').forEach(function(e
) {
1572 e
.classList
.remove('focus');
1575 elem
.classList
.add('focus');
1578 elem
.parentNode
.scrollTop
= elem
.offsetTop
- elem
.parentNode
.offsetTop
;
1584 createChoiceElement: function(sb
, value
, label
) {
1585 var tpl
= sb
.querySelector(this.options
.create_template
),
1589 markup
= (tpl
.textContent
|| tpl
.innerHTML
|| tpl
.firstChild
.data
).replace(/^<!--|--!?>$/, '').trim();
1591 markup
= '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1593 var new_item
= E(markup
.replace(/{{value}}/g, '%h'.format(value
))),
1594 placeholder
= new_item
.querySelector('[data-label-placeholder]');
1597 var content
= E('span', {}, label
|| this.choices
[value
] || [ value
]);
1599 while (content
.firstChild
)
1600 placeholder
.parentNode
.insertBefore(content
.firstChild
, placeholder
);
1602 placeholder
.parentNode
.removeChild(placeholder
);
1605 if (this.options
.multiple
)
1606 this.transformItem(sb
, new_item
);
1612 createItems: function(sb
, value
) {
1614 val
= (value
|| '').trim(),
1615 ul
= sb
.querySelector('ul');
1617 if (!sbox
.options
.multiple
)
1618 val
= val
.length
? [ val
] : [];
1620 val
= val
.length
? val
.split(/\s+/) : [];
1622 val
.forEach(function(item
) {
1623 var new_item
= null;
1625 ul
.childNodes
.forEach(function(li
) {
1626 if (li
.getAttribute
&& li
.getAttribute('data-value') === item
)
1631 new_item
= sbox
.createChoiceElement(sb
, item
);
1633 if (!sbox
.options
.multiple
) {
1634 var old
= ul
.querySelector('li[created]');
1636 ul
.removeChild(old
);
1638 new_item
.setAttribute('created', '');
1641 new_item
= ul
.insertBefore(new_item
, ul
.lastElementChild
);
1644 sbox
.toggleItem(sb
, new_item
, true);
1645 sbox
.setFocus(sb
, new_item
, true);
1650 * Remove all existing choices from the dropdown menu.
1652 * This function removes all preexisting dropdown choices from the widget,
1653 * keeping only choices currently being selected unless `reset_values` is
1654 * given, in which case all choices and deselected and removed.
1657 * @memberof LuCI.ui.Dropdown
1658 * @param {boolean} [reset_value=false]
1659 * If set to `true`, deselect and remove selected choices as well instead
1662 clearChoices: function(reset_value
) {
1663 var ul
= this.node
.querySelector('ul'),
1664 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [],
1665 len
= lis
.length
- (this.options
.create
? 1 : 0),
1666 val
= reset_value
? null : this.getValue();
1668 for (var i
= 0; i
< len
; i
++) {
1669 var lival
= lis
[i
].getAttribute('data-value');
1671 (!this.options
.multiple
&& val
!= lival
) ||
1672 (this.options
.multiple
&& val
.indexOf(lival
) == -1))
1673 ul
.removeChild(lis
[i
]);
1677 this.setValues(this.node
, {});
1681 * Add new choices to the dropdown menu.
1683 * This function adds further choices to an existing dropdown menu,
1684 * ignoring choice values which are already present.
1687 * @memberof LuCI.ui.Dropdown
1688 * @param {string[]} values
1689 * The choice values to add to the dropdown widget.
1691 * @param {Object<string, *>} labels
1692 * The choice label values to use when adding dropdown choices. If no
1693 * label is found for a particular choice value, the value itself is used
1694 * as label text. Choice labels may be any valid value accepted by
1695 * {@link LuCI.dom#content}.
1697 addChoices: function(values
, labels
) {
1699 ul
= sb
.querySelector('ul'),
1700 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [];
1702 if (!Array
.isArray(values
))
1703 values
= L
.toArray(values
);
1705 if (!L
.isObject(labels
))
1708 for (var i
= 0; i
< values
.length
; i
++) {
1711 for (var j
= 0; j
< lis
.length
; j
++) {
1712 if (lis
[j
].getAttribute('data-value') === values
[i
]) {
1722 this.createChoiceElement(sb
, values
[i
], labels
[values
[i
]]),
1723 ul
.lastElementChild
);
1728 * Close all open dropdown widgets in the current document.
1730 closeAllDropdowns: function() {
1731 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1732 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1737 handleClick: function(ev
) {
1738 var sb
= ev
.currentTarget
;
1740 if (!sb
.hasAttribute('open')) {
1741 if (!matchesElem(ev
.target
, 'input'))
1742 this.openDropdown(sb
);
1745 var li
= findParent(ev
.target
, 'li');
1746 if (li
&& li
.parentNode
.classList
.contains('dropdown'))
1747 this.toggleItem(sb
, li
);
1748 else if (li
&& li
.parentNode
.classList
.contains('preview'))
1749 this.closeDropdown(sb
);
1750 else if (matchesElem(ev
.target
, 'span.open, span.more'))
1751 this.closeDropdown(sb
);
1754 ev
.preventDefault();
1755 ev
.stopPropagation();
1759 handleKeydown: function(ev
) {
1760 var sb
= ev
.currentTarget
,
1761 ul
= sb
.querySelector('ul.dropdown');
1763 if (matchesElem(ev
.target
, 'input'))
1766 if (!sb
.hasAttribute('open')) {
1767 switch (ev
.keyCode
) {
1772 this.openDropdown(sb
);
1773 ev
.preventDefault();
1777 var active
= findParent(document
.activeElement
, 'li');
1779 switch (ev
.keyCode
) {
1781 this.closeDropdown(sb
);
1782 ev
.stopPropagation();
1787 if (!active
.hasAttribute('selected'))
1788 this.toggleItem(sb
, active
);
1789 this.closeDropdown(sb
);
1790 ev
.preventDefault();
1796 this.toggleItem(sb
, active
);
1797 ev
.preventDefault();
1802 if (active
&& active
.previousElementSibling
) {
1803 this.setFocus(sb
, active
.previousElementSibling
);
1804 ev
.preventDefault();
1806 else if (document
.activeElement
=== ul
) {
1807 this.setFocus(sb
, ul
.lastElementChild
);
1808 ev
.preventDefault();
1813 if (active
&& active
.nextElementSibling
) {
1814 var li
= active
.nextElementSibling
;
1815 this.setFocus(sb
, li
);
1816 if (this.options
.create
&& li
== li
.parentNode
.lastElementChild
) {
1817 var input
= li
.querySelector('input');
1818 if (input
) input
.focus();
1820 ev
.preventDefault();
1822 else if (document
.activeElement
=== ul
) {
1823 this.setFocus(sb
, ul
.firstElementChild
);
1824 ev
.preventDefault();
1832 handleDropdownClose: function(ev
) {
1833 var sb
= ev
.currentTarget
;
1835 this.closeDropdown(sb
, true);
1839 handleDropdownSelect: function(ev
) {
1840 var sb
= ev
.currentTarget
,
1841 li
= findParent(ev
.target
, 'li');
1846 this.toggleItem(sb
, li
);
1847 this.closeDropdown(sb
, true);
1851 handleFocus: function(ev
) {
1852 var sb
= ev
.currentTarget
;
1854 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1855 if (s
!== sb
|| sb
.hasAttribute('open'))
1856 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1861 handleCanaryFocus: function(ev
) {
1862 this.closeDropdown(ev
.currentTarget
.parentNode
);
1866 handleCreateKeydown: function(ev
) {
1867 var input
= ev
.currentTarget
,
1868 li
= findParent(input
, 'li'),
1869 sb
= findParent(li
, '.cbi-dropdown');
1871 switch (ev
.keyCode
) {
1873 ev
.preventDefault();
1875 if (input
.classList
.contains('cbi-input-invalid'))
1878 this.createItems(sb
, input
.value
);
1884 if (li
.previousElementSibling
) {
1885 this.handleCreateBlur(ev
);
1886 this.setFocus(sb
, li
.previousElementSibling
, true);
1893 handleCreateFocus: function(ev
) {
1894 var input
= ev
.currentTarget
,
1895 li
= findParent(input
, 'li'),
1896 cbox
= li
.querySelector('input[type="checkbox"]'),
1897 sb
= findParent(input
, '.cbi-dropdown');
1900 cbox
.checked
= true;
1902 sb
.setAttribute('locked-in', '');
1903 this.setFocus(sb
, li
, true);
1907 handleCreateBlur: function(ev
) {
1908 var input
= ev
.currentTarget
,
1909 cbox
= findParent(input
, 'li').querySelector('input[type="checkbox"]'),
1910 sb
= findParent(input
, '.cbi-dropdown');
1913 cbox
.checked
= false;
1915 sb
.removeAttribute('locked-in');
1919 handleCreateClick: function(ev
) {
1920 ev
.currentTarget
.querySelector(this.options
.create_query
).focus();
1924 setValue: function(values
) {
1925 if (this.options
.multiple
) {
1926 if (!Array
.isArray(values
))
1927 values
= (values
!= null && values
!= '') ? [ values
] : [];
1931 for (var i
= 0; i
< values
.length
; i
++)
1932 v
[values
[i
]] = true;
1934 this.setValues(this.node
, v
);
1939 if (values
!= null) {
1940 if (Array
.isArray(values
))
1941 v
[values
[0]] = true;
1946 this.setValues(this.node
, v
);
1951 getValue: function() {
1952 var div
= this.node
.lastElementChild
,
1953 h
= div
.querySelectorAll('input[type="hidden"]'),
1956 for (var i
= 0; i
< h
.length
; i
++)
1959 return this.options
.multiple
? v
: v
[0];
1964 * Instantiate a rich dropdown choice widget allowing custom values.
1966 * @constructor Combobox
1968 * @augments LuCI.ui.Dropdown
1972 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1973 * to enter custom values. Historically, comboboxes used to be a dedicated
1974 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1975 * with a set of enforced default properties for easier instantiation.
1977 * UI widget instances are usually not supposed to be created by view code
1978 * directly, instead they're implicitely created by `LuCI.form` when
1979 * instantiating CBI forms.
1981 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1982 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1983 * external JavaScript, use `L.require("ui").then(...)` and access the
1984 * `Combobox` property of the class instance value.
1986 * @param {string|string[]} [value=null]
1987 * The initial input value(s).
1989 * @param {Object<string, *>} choices
1990 * Object containing the selectable choices of the widget. The object keys
1991 * serve as values for the different choices while the values are used as
1994 * @param {LuCI.ui.Combobox.InitOptions} [options]
1995 * Object describing the widget specific options to initialize the dropdown.
1997 var UICombobox
= UIDropdown
.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1999 * Comboboxes support the same properties as
2000 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2001 * specific values for the following properties:
2003 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2004 * @memberof LuCI.ui.Combobox
2006 * @property {boolean} multiple=false
2007 * Since Comboboxes never allow selecting multiple values, this property
2008 * is forcibly set to `false`.
2010 * @property {boolean} create=true
2011 * Since Comboboxes always allow custom choice values, this property is
2012 * forcibly set to `true`.
2014 * @property {boolean} optional=true
2015 * Since Comboboxes are always optional, this property is forcibly set to
2018 __init__: function(value
, choices
, options
) {
2019 this.super('__init__', [ value
, choices
, Object
.assign({
2020 select_placeholder
: _('-- Please choose --'),
2021 custom_placeholder
: _('-- custom --'),
2033 * Instantiate a combo button widget offering multiple action choices.
2035 * @constructor ComboButton
2037 * @augments LuCI.ui.Dropdown
2041 * The `ComboButton` class implements a button element which can be expanded
2042 * into a dropdown to chose from a set of different action choices.
2044 * UI widget instances are usually not supposed to be created by view code
2045 * directly, instead they're implicitely created by `LuCI.form` when
2046 * instantiating CBI forms.
2048 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2049 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
2050 * external JavaScript, use `L.require("ui").then(...)` and access the
2051 * `ComboButton` property of the class instance value.
2053 * @param {string|string[]} [value=null]
2054 * The initial input value(s).
2056 * @param {Object<string, *>} choices
2057 * Object containing the selectable choices of the widget. The object keys
2058 * serve as values for the different choices while the values are used as
2061 * @param {LuCI.ui.ComboButton.InitOptions} [options]
2062 * Object describing the widget specific options to initialize the button.
2064 var UIComboButton
= UIDropdown
.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
2066 * ComboButtons support the same properties as
2067 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2068 * specific values for some properties and add aditional button specific
2071 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2072 * @memberof LuCI.ui.ComboButton
2074 * @property {boolean} multiple=false
2075 * Since ComboButtons never allow selecting multiple actions, this property
2076 * is forcibly set to `false`.
2078 * @property {boolean} create=false
2079 * Since ComboButtons never allow creating custom choices, this property
2080 * is forcibly set to `false`.
2082 * @property {boolean} optional=false
2083 * Since ComboButtons must always select one action, this property is
2084 * forcibly set to `false`.
2086 * @property {Object<string, string>} [classes]
2087 * Specifies a mapping of choice values to CSS class names. If an action
2088 * choice is selected by the user and if a corresponding entry exists in
2089 * the `classes` object, the class names corresponding to the selected
2090 * value are set on the button element.
2092 * This is useful to apply different button styles, such as colors, to the
2093 * combined button depending on the selected action.
2095 * @property {function} [click]
2096 * Specifies a handler function to invoke when the user clicks the button.
2097 * This function will be called with the button DOM node as `this` context
2098 * and receive the DOM click event as first as well as the selected action
2099 * choice value as second argument.
2101 __init__: function(value
, choices
, options
) {
2102 this.super('__init__', [ value
, choices
, Object
.assign({
2112 render: function(/* ... */) {
2113 var node
= UIDropdown
.prototype.render
.apply(this, arguments
),
2114 val
= this.getValue();
2116 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2117 node
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2123 handleClick: function(ev
) {
2124 var sb
= ev
.currentTarget
,
2127 if (sb
.hasAttribute('open') || dom
.matches(t
, '.cbi-dropdown > span.open'))
2128 return UIDropdown
.prototype.handleClick
.apply(this, arguments
);
2130 if (this.options
.click
)
2131 return this.options
.click
.call(sb
, ev
, this.getValue());
2135 toggleItem: function(sb
/*, ... */) {
2136 var rv
= UIDropdown
.prototype.toggleItem
.apply(this, arguments
),
2137 val
= this.getValue();
2139 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2140 sb
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2142 sb
.setAttribute('class', 'cbi-dropdown');
2149 * Instantiate a dynamic list widget.
2151 * @constructor DynamicList
2153 * @augments LuCI.ui.AbstractElement
2157 * The `DynamicList` class implements a widget which allows the user to specify
2158 * an arbitrary amount of input values, either from free formed text input or
2159 * from a set of predefined choices.
2161 * UI widget instances are usually not supposed to be created by view code
2162 * directly, instead they're implicitely created by `LuCI.form` when
2163 * instantiating CBI forms.
2165 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2166 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2167 * external JavaScript, use `L.require("ui").then(...)` and access the
2168 * `DynamicList` property of the class instance value.
2170 * @param {string|string[]} [value=null]
2171 * The initial input value(s).
2173 * @param {Object<string, *>} [choices]
2174 * Object containing the selectable choices of the widget. The object keys
2175 * serve as values for the different choices while the values are used as
2176 * choice labels. If omitted, no default choices are presented to the user,
2177 * instead a plain text input field is rendered allowing the user to add
2178 * arbitrary values to the dynamic list.
2180 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2181 * Object describing the widget specific options to initialize the dynamic list.
2183 var UIDynamicList
= UIElement
.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2185 * In case choices are passed to the dynamic list contructor, the widget
2186 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2187 * but enforces specific values for some dropdown properties.
2189 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2190 * @memberof LuCI.ui.DynamicList
2192 * @property {boolean} multiple=false
2193 * Since dynamic lists never allow selecting multiple choices when adding
2194 * another list item, this property is forcibly set to `false`.
2196 * @property {boolean} optional=true
2197 * Since dynamic lists use an embedded dropdown to present a list of
2198 * predefined choice values, the dropdown must be made optional to allow
2199 * it to remain unselected.
2201 __init__: function(values
, choices
, options
) {
2202 if (!Array
.isArray(values
))
2203 values
= (values
!= null && values
!= '') ? [ values
] : [];
2205 if (typeof(choices
) != 'object')
2208 this.values
= values
;
2209 this.choices
= choices
;
2210 this.options
= Object
.assign({}, options
, {
2217 render: function() {
2219 'id': this.options
.id
,
2220 'class': 'cbi-dynlist',
2221 'disabled': this.options
.disabled
? '' : null
2222 }, E('div', { 'class': 'add-item control-group' }));
2225 if (this.options
.placeholder
!= null)
2226 this.options
.select_placeholder
= this.options
.placeholder
;
2228 var cbox
= new UICombobox(null, this.choices
, this.options
);
2230 dl
.lastElementChild
.appendChild(cbox
.render());
2233 var inputEl
= E('input', {
2234 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
2236 'class': 'cbi-input-text',
2237 'placeholder': this.options
.placeholder
,
2238 'disabled': this.options
.disabled
? '' : null
2241 dl
.lastElementChild
.appendChild(inputEl
);
2242 dl
.lastElementChild
.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2244 if (this.options
.datatype
|| this.options
.validate
)
2245 UI
.prototype.addValidator(inputEl
, this.options
.datatype
|| 'string',
2246 true, this.options
.validate
, 'blur', 'keyup');
2249 for (var i
= 0; i
< this.values
.length
; i
++) {
2250 var label
= this.choices
? this.choices
[this.values
[i
]] : null;
2252 if (dom
.elem(label
))
2253 label
= label
.cloneNode(true);
2255 this.addItem(dl
, this.values
[i
], label
);
2258 return this.bind(dl
);
2262 bind: function(dl
) {
2263 dl
.addEventListener('click', L
.bind(this.handleClick
, this));
2264 dl
.addEventListener('keydown', L
.bind(this.handleKeydown
, this));
2265 dl
.addEventListener('cbi-dropdown-change', L
.bind(this.handleDropdownChange
, this));
2269 this.setUpdateEvents(dl
, 'cbi-dynlist-change');
2270 this.setChangeEvents(dl
, 'cbi-dynlist-change');
2272 dom
.bindClassInstance(dl
, this);
2278 addItem: function(dl
, value
, text
, flash
) {
2280 new_item
= E('div', { 'class': flash
? 'item flash' : 'item', 'tabindex': 0 }, [
2281 E('span', {}, [ text
|| value
]),
2284 'name': this.options
.name
,
2285 'value': value
})]);
2287 dl
.querySelectorAll('.item').forEach(function(item
) {
2291 var hidden
= item
.querySelector('input[type="hidden"]');
2293 if (hidden
&& hidden
.parentNode
!== item
)
2296 if (hidden
&& hidden
.value
=== value
)
2301 var ai
= dl
.querySelector('.add-item');
2302 ai
.parentNode
.insertBefore(new_item
, ai
);
2305 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2317 removeItem: function(dl
, item
) {
2318 var value
= item
.querySelector('input[type="hidden"]').value
;
2319 var sb
= dl
.querySelector('.cbi-dropdown');
2321 sb
.querySelectorAll('ul > li').forEach(function(li
) {
2322 if (li
.getAttribute('data-value') === value
) {
2323 if (li
.hasAttribute('dynlistcustom'))
2324 li
.parentNode
.removeChild(li
);
2326 li
.removeAttribute('unselectable');
2330 item
.parentNode
.removeChild(item
);
2332 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2344 handleClick: function(ev
) {
2345 var dl
= ev
.currentTarget
,
2346 item
= findParent(ev
.target
, '.item');
2348 if (this.options
.disabled
)
2352 this.removeItem(dl
, item
);
2354 else if (matchesElem(ev
.target
, '.cbi-button-add')) {
2355 var input
= ev
.target
.previousElementSibling
;
2356 if (input
.value
.length
&& !input
.classList
.contains('cbi-input-invalid')) {
2357 this.addItem(dl
, input
.value
, null, true);
2364 handleDropdownChange: function(ev
) {
2365 var dl
= ev
.currentTarget
,
2366 sbIn
= ev
.detail
.instance
,
2367 sbEl
= ev
.detail
.element
,
2368 sbVal
= ev
.detail
.value
;
2373 sbIn
.setValues(sbEl
, null);
2374 sbVal
.element
.setAttribute('unselectable', '');
2376 if (sbVal
.element
.hasAttribute('created')) {
2377 sbVal
.element
.removeAttribute('created');
2378 sbVal
.element
.setAttribute('dynlistcustom', '');
2381 var label
= sbVal
.text
;
2383 if (sbVal
.element
) {
2386 for (var i
= 0; i
< sbVal
.element
.childNodes
.length
; i
++)
2387 label
.appendChild(sbVal
.element
.childNodes
[i
].cloneNode(true));
2390 this.addItem(dl
, sbVal
.value
, label
, true);
2394 handleKeydown: function(ev
) {
2395 var dl
= ev
.currentTarget
,
2396 item
= findParent(ev
.target
, '.item');
2399 switch (ev
.keyCode
) {
2400 case 8: /* backspace */
2401 if (item
.previousElementSibling
)
2402 item
.previousElementSibling
.focus();
2404 this.removeItem(dl
, item
);
2407 case 46: /* delete */
2408 if (item
.nextElementSibling
) {
2409 if (item
.nextElementSibling
.classList
.contains('item'))
2410 item
.nextElementSibling
.focus();
2412 item
.nextElementSibling
.firstElementChild
.focus();
2415 this.removeItem(dl
, item
);
2419 else if (matchesElem(ev
.target
, '.cbi-input-text')) {
2420 switch (ev
.keyCode
) {
2421 case 13: /* enter */
2422 if (ev
.target
.value
.length
&& !ev
.target
.classList
.contains('cbi-input-invalid')) {
2423 this.addItem(dl
, ev
.target
.value
, null, true);
2424 ev
.target
.value
= '';
2429 ev
.preventDefault();
2436 getValue: function() {
2437 var items
= this.node
.querySelectorAll('.item > input[type="hidden"]'),
2438 input
= this.node
.querySelector('.add-item > input[type="text"]'),
2441 for (var i
= 0; i
< items
.length
; i
++)
2442 v
.push(items
[i
].value
);
2444 if (input
&& input
.value
!= null && input
.value
.match(/\S/) &&
2445 input
.classList
.contains('cbi-input-invalid') == false &&
2446 v
.filter(function(s
) { return s
== input
.value
}).length
== 0)
2447 v
.push(input
.value
);
2453 setValue: function(values
) {
2454 if (!Array
.isArray(values
))
2455 values
= (values
!= null && values
!= '') ? [ values
] : [];
2457 var items
= this.node
.querySelectorAll('.item');
2459 for (var i
= 0; i
< items
.length
; i
++)
2460 if (items
[i
].parentNode
=== this.node
)
2461 this.removeItem(this.node
, items
[i
]);
2463 for (var i
= 0; i
< values
.length
; i
++)
2464 this.addItem(this.node
, values
[i
],
2465 this.choices
? this.choices
[values
[i
]] : null);
2469 * Add new suggested choices to the dynamic list.
2471 * This function adds further choices to an existing dynamic list,
2472 * ignoring choice values which are already present.
2475 * @memberof LuCI.ui.DynamicList
2476 * @param {string[]} values
2477 * The choice values to add to the dynamic lists suggestion dropdown.
2479 * @param {Object<string, *>} labels
2480 * The choice label values to use when adding suggested choices. If no
2481 * label is found for a particular choice value, the value itself is used
2482 * as label text. Choice labels may be any valid value accepted by
2483 * {@link LuCI.dom#content}.
2485 addChoices: function(values
, labels
) {
2486 var dl
= this.node
.lastElementChild
.firstElementChild
;
2487 dom
.callClassMethod(dl
, 'addChoices', values
, labels
);
2491 * Remove all existing choices from the dynamic list.
2493 * This function removes all preexisting suggested choices from the widget.
2496 * @memberof LuCI.ui.DynamicList
2498 clearChoices: function() {
2499 var dl
= this.node
.lastElementChild
.firstElementChild
;
2500 dom
.callClassMethod(dl
, 'clearChoices');
2505 * Instantiate a hidden input field widget.
2507 * @constructor Hiddenfield
2509 * @augments LuCI.ui.AbstractElement
2513 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2514 * which allows to store form data without exposing it to the user.
2516 * UI widget instances are usually not supposed to be created by view code
2517 * directly, instead they're implicitely created by `LuCI.form` when
2518 * instantiating CBI forms.
2520 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2521 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2522 * external JavaScript, use `L.require("ui").then(...)` and access the
2523 * `Hiddenfield` property of the class instance value.
2525 * @param {string|string[]} [value=null]
2526 * The initial input value.
2528 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2529 * Object describing the widget specific options to initialize the hidden input.
2531 var UIHiddenfield
= UIElement
.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2532 __init__: function(value
, options
) {
2534 this.options
= Object
.assign({
2540 render: function() {
2541 var hiddenEl
= E('input', {
2542 'id': this.options
.id
,
2547 return this.bind(hiddenEl
);
2551 bind: function(hiddenEl
) {
2552 this.node
= hiddenEl
;
2554 dom
.bindClassInstance(hiddenEl
, this);
2560 getValue: function() {
2561 return this.node
.value
;
2565 setValue: function(value
) {
2566 this.node
.value
= value
;
2571 * Instantiate a file upload widget.
2573 * @constructor FileUpload
2575 * @augments LuCI.ui.AbstractElement
2579 * The `FileUpload` class implements a widget which allows the user to upload,
2580 * browse, select and delete files beneath a predefined remote directory.
2582 * UI widget instances are usually not supposed to be created by view code
2583 * directly, instead they're implicitely created by `LuCI.form` when
2584 * instantiating CBI forms.
2586 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2587 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2588 * external JavaScript, use `L.require("ui").then(...)` and access the
2589 * `FileUpload` property of the class instance value.
2591 * @param {string|string[]} [value=null]
2592 * The initial input value.
2594 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2595 * Object describing the widget specific options to initialize the file
2598 var UIFileUpload
= UIElement
.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2600 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2601 * the following properties are recognized:
2603 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2604 * @memberof LuCI.ui.FileUpload
2606 * @property {boolean} [show_hidden=false]
2607 * Specifies whether hidden files should be displayed when browsing remote
2608 * files. Note that this is not a security feature, hidden files are always
2609 * present in the remote file listings received, this option merely controls
2610 * whether they're displayed or not.
2612 * @property {boolean} [enable_upload=true]
2613 * Specifies whether the widget allows the user to upload files. If set to
2614 * `false`, only existing files may be selected. Note that this is not a
2615 * security feature. Whether file upload requests are accepted remotely
2616 * depends on the ACL setup for the current session. This option merely
2617 * controls whether the upload controls are rendered or not.
2619 * @property {boolean} [enable_remove=true]
2620 * Specifies whether the widget allows the user to delete remove files.
2621 * If set to `false`, existing files may not be removed. Note that this is
2622 * not a security feature. Whether file delete requests are accepted
2623 * remotely depends on the ACL setup for the current session. This option
2624 * merely controls whether the file remove controls are rendered or not.
2626 * @property {string} [root_directory=/etc/luci-uploads]
2627 * Specifies the remote directory the upload and file browsing actions take
2628 * place in. Browsing to directories outside of the root directory is
2629 * prevented by the widget. Note that this is not a security feature.
2630 * Whether remote directories are browseable or not solely depends on the
2631 * ACL setup for the current session.
2633 __init__: function(value
, options
) {
2635 this.options
= Object
.assign({
2637 enable_upload
: true,
2638 enable_remove
: true,
2639 root_directory
: '/etc/luci-uploads'
2644 bind: function(browserEl
) {
2645 this.node
= browserEl
;
2647 this.setUpdateEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2648 this.setChangeEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2650 dom
.bindClassInstance(browserEl
, this);
2656 render: function() {
2657 return L
.resolveDefault(this.value
!= null ? fs
.stat(this.value
) : null).then(L
.bind(function(stat
) {
2660 if (L
.isObject(stat
) && stat
.type
!= 'directory')
2663 if (this.stat
!= null)
2664 label
= [ this.iconForType(this.stat
.type
), ' %s (%1000mB)'.format(this.truncatePath(this.stat
.path
), this.stat
.size
) ];
2665 else if (this.value
!= null)
2666 label
= [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value
), _('File not accessible')) ];
2668 label
= [ _('Select file…') ];
2670 return this.bind(E('div', { 'id': this.options
.id
}, [
2673 'click': UI
.prototype.createHandlerFn(this, 'handleFileBrowser'),
2674 'disabled': this.options
.disabled
? '' : null
2677 'class': 'cbi-filebrowser'
2681 'name': this.options
.name
,
2689 truncatePath: function(path
) {
2690 if (path
.length
> 50)
2691 path
= path
.substring(0, 25) + '…' + path
.substring(path
.length
- 25);
2697 iconForType: function(type
) {
2701 'src': L
.resource('cbi/link.svg'),
2703 'title': _('Symbolic link'),
2709 'src': L
.resource('cbi/folder.svg'),
2711 'title': _('Directory'),
2717 'src': L
.resource('cbi/file.svg'),
2726 canonicalizePath: function(path
) {
2727 return path
.replace(/\/{2,}/, '/')
2728 .replace(/\/\.(\/|$)/g, '/')
2729 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2730 .replace(/\/$/, '');
2734 splitPath: function(path
) {
2735 var croot
= this.canonicalizePath(this.options
.root_directory
|| '/'),
2736 cpath
= this.canonicalizePath(path
|| '/');
2738 if (cpath
.length
<= croot
.length
)
2741 if (cpath
.charAt(croot
.length
) != '/')
2744 var parts
= cpath
.substring(croot
.length
+ 1).split(/\//);
2746 parts
.unshift(croot
);
2752 handleUpload: function(path
, list
, ev
) {
2753 var form
= ev
.target
.parentNode
,
2754 fileinput
= form
.querySelector('input[type="file"]'),
2755 nameinput
= form
.querySelector('input[type="text"]'),
2756 filename
= (nameinput
.value
!= null ? nameinput
.value
: '').trim();
2758 ev
.preventDefault();
2760 if (filename
== '' || filename
.match(/\//) || fileinput
.files
[0] == null)
2763 var existing
= list
.filter(function(e
) { return e
.name
== filename
})[0];
2765 if (existing
!= null && existing
.type
== 'directory')
2766 return alert(_('A directory with the same name already exists.'));
2767 else if (existing
!= null && !confirm(_('Overwrite existing file "%s" ?').format(filename
)))
2770 var data
= new FormData();
2772 data
.append('sessionid', L
.env
.sessionid
);
2773 data
.append('filename', path
+ '/' + filename
);
2774 data
.append('filedata', fileinput
.files
[0]);
2776 return request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
2777 progress
: L
.bind(function(btn
, ev
) {
2778 btn
.firstChild
.data
= '%.2f%%'.format((ev
.loaded
/ ev
.total
) * 100);
2780 }).then(L
.bind(function(path
, ev
, res
) {
2781 var reply
= res
.json();
2783 if (L
.isObject(reply
) && reply
.failure
)
2784 alert(_('Upload request failed: %s').format(reply
.message
));
2786 return this.handleSelect(path
, null, ev
);
2787 }, this, path
, ev
));
2791 handleDelete: function(path
, fileStat
, ev
) {
2792 var parent
= path
.replace(/\/[^\/]+$/, '') || '/',
2793 name
= path
.replace(/^.+\//, ''),
2796 ev
.preventDefault();
2798 if (fileStat
.type
== 'directory')
2799 msg
= _('Do you really want to recursively delete the directory "%s" ?').format(name
);
2801 msg
= _('Do you really want to delete "%s" ?').format(name
);
2804 var button
= this.node
.firstElementChild
,
2805 hidden
= this.node
.lastElementChild
;
2807 if (path
== hidden
.value
) {
2808 dom
.content(button
, _('Select file…'));
2812 return fs
.remove(path
).then(L
.bind(function(parent
, ev
) {
2813 return this.handleSelect(parent
, null, ev
);
2814 }, this, parent
, ev
)).catch(function(err
) {
2815 alert(_('Delete request failed: %s').format(err
.message
));
2821 renderUpload: function(path
, list
) {
2822 if (!this.options
.enable_upload
)
2828 'class': 'btn cbi-button-positive',
2829 'click': function(ev
) {
2830 var uploadForm
= ev
.target
.nextElementSibling
,
2831 fileInput
= uploadForm
.querySelector('input[type="file"]');
2833 ev
.target
.style
.display
= 'none';
2834 uploadForm
.style
.display
= '';
2837 }, _('Upload file…')),
2838 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2841 'style': 'display:none',
2842 'change': function(ev
) {
2843 var nameinput
= ev
.target
.parentNode
.querySelector('input[type="text"]'),
2844 uploadbtn
= ev
.target
.parentNode
.querySelector('button.cbi-button-save');
2846 nameinput
.value
= ev
.target
.value
.replace(/^.+[\/\\]/, '');
2847 uploadbtn
.disabled
= false;
2852 'click': function(ev
) {
2853 ev
.preventDefault();
2854 ev
.target
.previousElementSibling
.click();
2856 }, [ _('Browse…') ]),
2857 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2859 'class': 'btn cbi-button-save',
2860 'click': UI
.prototype.createHandlerFn(this, 'handleUpload', path
, list
),
2862 }, [ _('Upload file') ])
2868 renderListing: function(container
, path
, list
) {
2869 var breadcrumb
= E('p'),
2872 list
.sort(function(a
, b
) {
2873 return L
.naturalCompare(a
.type
== 'directory', b
.type
== 'directory') ||
2874 L
.naturalCompare(a
.name
, b
.name
);
2877 for (var i
= 0; i
< list
.length
; i
++) {
2878 if (!this.options
.show_hidden
&& list
[i
].name
.charAt(0) == '.')
2881 var entrypath
= this.canonicalizePath(path
+ '/' + list
[i
].name
),
2882 selected
= (entrypath
== this.node
.lastElementChild
.value
),
2883 mtime
= new Date(list
[i
].mtime
* 1000);
2885 rows
.appendChild(E('li', [
2886 E('div', { 'class': 'name' }, [
2887 this.iconForType(list
[i
].type
),
2891 'style': selected
? 'font-weight:bold' : null,
2892 'click': UI
.prototype.createHandlerFn(this, 'handleSelect',
2893 entrypath
, list
[i
].type
!= 'directory' ? list
[i
] : null)
2894 }, '%h'.format(list
[i
].name
))
2896 E('div', { 'class': 'mtime hide-xs' }, [
2897 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2898 mtime
.getFullYear(),
2899 mtime
.getMonth() + 1,
2906 selected
? E('button', {
2908 'click': UI
.prototype.createHandlerFn(this, 'handleReset')
2909 }, [ _('Deselect') ]) : '',
2910 this.options
.enable_remove
? E('button', {
2911 'class': 'btn cbi-button-negative',
2912 'click': UI
.prototype.createHandlerFn(this, 'handleDelete', entrypath
, list
[i
])
2913 }, [ _('Delete') ]) : ''
2918 if (!rows
.firstElementChild
)
2919 rows
.appendChild(E('em', _('No entries in this directory')));
2921 var dirs
= this.splitPath(path
),
2924 for (var i
= 0; i
< dirs
.length
; i
++) {
2925 cur
= cur
? cur
+ '/' + dirs
[i
] : dirs
[i
];
2926 dom
.append(breadcrumb
, [
2930 'click': UI
.prototype.createHandlerFn(this, 'handleSelect', cur
|| '/', null)
2931 }, dirs
[i
] != '' ? '%h'.format(dirs
[i
]) : E('em', '(root)')),
2935 dom
.content(container
, [
2938 E('div', { 'class': 'right' }, [
2939 this.renderUpload(path
, list
),
2943 'click': UI
.prototype.createHandlerFn(this, 'handleCancel')
2950 handleCancel: function(ev
) {
2951 var button
= this.node
.firstElementChild
,
2952 browser
= button
.nextElementSibling
;
2954 browser
.classList
.remove('open');
2955 button
.style
.display
= '';
2957 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2959 ev
.preventDefault();
2963 handleReset: function(ev
) {
2964 var button
= this.node
.firstElementChild
,
2965 hidden
= this.node
.lastElementChild
;
2968 dom
.content(button
, _('Select file…'));
2970 this.handleCancel(ev
);
2974 handleSelect: function(path
, fileStat
, ev
) {
2975 var browser
= dom
.parent(ev
.target
, '.cbi-filebrowser'),
2976 ul
= browser
.querySelector('ul');
2978 if (fileStat
== null) {
2979 dom
.content(ul
, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2980 L
.resolveDefault(fs
.list(path
), []).then(L
.bind(this.renderListing
, this, browser
, path
));
2983 var button
= this.node
.firstElementChild
,
2984 hidden
= this.node
.lastElementChild
;
2986 path
= this.canonicalizePath(path
);
2988 dom
.content(button
, [
2989 this.iconForType(fileStat
.type
),
2990 ' %s (%1000mB)'.format(this.truncatePath(path
), fileStat
.size
)
2993 browser
.classList
.remove('open');
2994 button
.style
.display
= '';
2995 hidden
.value
= path
;
2997 this.stat
= Object
.assign({ path
: path
}, fileStat
);
2998 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail
: this.stat
}));
3003 handleFileBrowser: function(ev
) {
3004 var button
= ev
.target
,
3005 browser
= button
.nextElementSibling
,
3006 path
= this.stat
? this.stat
.path
.replace(/\/[^\/]+$/, '') : (this.options
.initial_directory
|| this.options
.root_directory
);
3008 if (path
.indexOf(this.options
.root_directory
) != 0)
3009 path
= this.options
.root_directory
;
3011 ev
.preventDefault();
3013 return L
.resolveDefault(fs
.list(path
), []).then(L
.bind(function(button
, browser
, path
, list
) {
3014 document
.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl
) {
3015 dom
.findClassInstance(browserEl
).handleCancel(ev
);
3018 button
.style
.display
= 'none';
3019 browser
.classList
.add('open');
3021 return this.renderListing(browser
, path
, list
);
3022 }, this, button
, browser
, path
));
3026 getValue: function() {
3027 return this.node
.lastElementChild
.value
;
3031 setValue: function(value
) {
3032 this.node
.lastElementChild
.value
= value
;
3037 function scrubMenu(node
) {
3038 var hasSatisfiedChild
= false;
3040 if (L
.isObject(node
.children
)) {
3041 for (var k
in node
.children
) {
3042 var child
= scrubMenu(node
.children
[k
]);
3044 if (child
.title
&& !child
.firstchild_ineligible
)
3045 hasSatisfiedChild
= hasSatisfiedChild
|| child
.satisfied
;
3049 if (L
.isObject(node
.action
) &&
3050 node
.action
.type
== 'firstchild' &&
3051 hasSatisfiedChild
== false)
3052 node
.satisfied
= false;
3067 var UIMenu
= baseclass
.singleton(/** @lends LuCI.ui.menu.prototype */ {
3069 * @typedef {Object} MenuNode
3070 * @memberof LuCI.ui.menu
3072 * @property {string} name - The internal name of the node, as used in the URL
3073 * @property {number} order - The sort index of the menu node
3074 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
3075 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
3076 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
3077 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
3081 * Load and cache current menu tree.
3083 * @returns {Promise<LuCI.ui.menu.MenuNode>}
3084 * Returns a promise resolving to the root element of the menu tree.
3087 if (this.menu
== null)
3088 this.menu
= session
.getLocalData('menu');
3090 if (!L
.isObject(this.menu
)) {
3091 this.menu
= request
.get(L
.url('admin/menu')).then(L
.bind(function(menu
) {
3092 this.menu
= scrubMenu(menu
.json());
3093 session
.setLocalData('menu', this.menu
);
3099 return Promise
.resolve(this.menu
);
3103 * Flush the internal menu cache to force loading a new structure on the
3106 flushCache: function() {
3107 session
.setLocalData('menu', null);
3111 * @param {LuCI.ui.menu.MenuNode} [node]
3112 * The menu node to retrieve the children for. Defaults to the menu's
3113 * internal root node if omitted.
3115 * @returns {LuCI.ui.menu.MenuNode[]}
3116 * Returns an array of child menu nodes.
3118 getChildren: function(node
) {
3124 for (var k
in node
.children
) {
3125 if (!node
.children
.hasOwnProperty(k
))
3128 if (!node
.children
[k
].satisfied
)
3131 if (!node
.children
[k
].hasOwnProperty('title'))
3134 var subnode
= Object
.assign(node
.children
[k
], { name
: k
});
3136 if (L
.isObject(subnode
.action
) && subnode
.action
.path
!= null &&
3137 (subnode
.action
.type
== 'alias' || subnode
.action
.type
== 'rewrite')) {
3138 var root
= this.menu
,
3139 path
= subnode
.action
.path
.split('/');
3141 for (var i
= 0; root
!= null && i
< path
.length
; i
++)
3142 root
= L
.isObject(root
.children
) ? root
.children
[path
[i
]] : null;
3145 subnode
= Object
.assign({}, subnode
, {
3146 children
: root
.children
,
3151 children
.push(subnode
);
3154 return children
.sort(function(a
, b
) {
3155 var wA
= a
.order
|| 1000,
3156 wB
= b
.order
|| 1000;
3161 return L
.naturalCompare(a
.name
, b
.name
);
3166 var UITable
= baseclass
.extend(/** @lends LuCI.ui.table.prototype */ {
3167 __init__: function(captions
, options
, placeholder
) {
3168 if (!Array
.isArray(captions
)) {
3169 this.initFromMarkup(captions
);
3174 var id
= options
.id
|| 'table%08x'.format(Math
.random() * 0xffffffff);
3176 var table
= E('table', { 'id': id
, 'class': 'table' }, [
3177 E('tr', { 'class': 'tr table-titles', 'click': UI
.prototype.createHandlerFn(this, 'handleSort') })
3182 this.options
= options
;
3184 var sorting
= this.getActiveSortState();
3186 for (var i
= 0; i
< captions
.length
; i
++) {
3187 if (captions
[i
] == null)
3190 var th
= E('th', { 'class': 'th' }, [ captions
[i
] ]);
3192 if (typeof(options
.captionClasses
) == 'object')
3193 DOMTokenList
.prototype.add
.apply(th
.classList
, L
.toArray(options
.captionClasses
[i
]));
3195 if (options
.sortable
!== false && (typeof(options
.sortable
) != 'object' || options
.sortable
[i
] !== false)) {
3196 th
.setAttribute('data-sortable-row', true);
3198 if (sorting
&& sorting
[0] == i
)
3199 th
.setAttribute('data-sort-direction', sorting
[1] ? 'desc' : 'asc');
3202 table
.firstElementChild
.appendChild(th
);
3206 var trow
= table
.appendChild(E('tr', { 'class': 'tr placeholder' })),
3207 td
= trow
.appendChild(E('td', { 'class': 'td' }, placeholder
));
3209 if (typeof(captionClasses
) == 'object')
3210 DOMTokenList
.prototype.add
.apply(td
.classList
, L
.toArray(captionClasses
[0]));
3213 DOMTokenList
.prototype.add
.apply(table
.classList
, L
.toArray(options
.classes
));
3216 update: function(data
, placeholder
) {
3217 var placeholder
= placeholder
|| this.options
.placeholder
|| _('No data', 'empty table placeholder'),
3218 sorting
= this.getActiveSortState();
3220 if (!Array
.isArray(data
))
3224 this.placeholder
= placeholder
;
3227 rows
= this.node
.querySelectorAll('tr, .tr'),
3229 headings
= [].slice
.call(this.node
.firstElementChild
.querySelectorAll('th, .th')),
3230 captionClasses
= this.options
.captionClasses
,
3231 trTag
= (rows
[0] && rows
[0].nodeName
== 'DIV') ? 'div' : 'tr',
3232 tdTag
= (headings
[0] && headings
[0].nodeName
== 'DIV') ? 'div' : 'td';
3235 var list
= data
.map(L
.bind(function(row
) {
3236 return [ this.deriveSortKey(row
[sorting
[0]], sorting
[0]), row
];
3239 list
.sort(function(a
, b
) {
3241 ? -L
.naturalCompare(a
[0], b
[0])
3242 : L
.naturalCompare(a
[0], b
[0]);
3247 list
.forEach(function(item
) {
3251 headings
.forEach(function(th
, i
) {
3252 if (i
== sorting
[0])
3253 th
.setAttribute('data-sort-direction', sorting
[1] ? 'desc' : 'asc');
3255 th
.removeAttribute('data-sort-direction');
3259 data
.forEach(function(row
) {
3260 trows
[n
] = E(trTag
, { 'class': 'tr' });
3262 for (var i
= 0; i
< headings
.length
; i
++) {
3263 var text
= (headings
[i
].innerText
|| '').trim();
3264 var raw_val
= Array
.isArray(row
[i
]) ? row
[i
][0] : null;
3265 var disp_val
= Array
.isArray(row
[i
]) ? row
[i
][1] : row
[i
];
3266 var td
= trows
[n
].appendChild(E(tdTag
, {
3268 'data-title': (text
!== '') ? text
: null,
3269 'data-value': raw_val
3270 }, (disp_val
!= null) ? ((disp_val
instanceof DocumentFragment
) ? disp_val
.cloneNode(true) : disp_val
) : ''));
3272 if (typeof(captionClasses
) == 'object')
3273 DOMTokenList
.prototype.add
.apply(td
.classList
, L
.toArray(captionClasses
[i
]));
3275 if (!td
.classList
.contains('cbi-section-actions'))
3276 headings
[i
].setAttribute('data-sortable-row', true);
3279 trows
[n
].classList
.add('cbi-rowstyle-%d'.format((n
++ % 2) ? 2 : 1));
3282 for (var i
= 0; i
< n
; i
++) {
3284 this.node
.replaceChild(trows
[i
], rows
[i
+1]);
3286 this.node
.appendChild(trows
[i
]);
3290 this.node
.removeChild(rows
[n
]);
3292 if (placeholder
&& this.node
.firstElementChild
=== this.node
.lastElementChild
) {
3293 var trow
= this.node
.appendChild(E(trTag
, { 'class': 'tr placeholder' })),
3294 td
= trow
.appendChild(E(tdTag
, { 'class': 'td' }, placeholder
));
3296 if (typeof(captionClasses
) == 'object')
3297 DOMTokenList
.prototype.add
.apply(td
.classList
, L
.toArray(captionClasses
[0]));
3303 render: function() {
3308 initFromMarkup: function(node
) {
3309 if (!dom
.elem(node
))
3310 node
= document
.querySelector(node
);
3313 throw 'Invalid table selector';
3316 headrow
= node
.querySelector('tr, .tr');
3321 options
.id
= node
.id
;
3322 options
.classes
= [].slice
.call(node
.classList
).filter(function(c
) { return c
!= 'table' });
3323 options
.sortable
= [];
3324 options
.captionClasses
= [];
3326 headrow
.querySelectorAll('th, .th').forEach(function(th
, i
) {
3327 options
.sortable
[i
] = !th
.classList
.contains('cbi-section-actions');
3328 options
.captionClasses
[i
] = [].slice
.call(th
.classList
).filter(function(c
) { return c
!= 'th' });
3331 headrow
.addEventListener('click', UI
.prototype.createHandlerFn(this, 'handleSort'));
3335 this.options
= options
;
3339 deriveSortKey: function(value
, index
) {
3340 var opts
= this.options
|| {},
3343 if (opts
.sortable
== true || opts
.sortable
== null)
3345 else if (typeof( opts
.sortable
) == 'object')
3346 hint
= opts
.sortable
[index
];
3348 if (dom
.elem(value
)) {
3349 if (value
.hasAttribute('data-value'))
3350 value
= value
.getAttribute('data-value');
3352 value
= (value
.innerText
|| '').trim();
3355 switch (hint
|| 'auto') {
3358 m
= /^([0-9a-fA-F:.]+)(?:\/([0-9a-fA-F:.]+))?$/.exec(value
);
3363 addr
= validation
.parseIPv6(m
[1]);
3364 mask
= m
[2] ? validation
.parseIPv6(m
[2]) : null;
3366 if (addr
&& mask
!= null)
3367 return '%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x'.format(
3368 addr
[0], addr
[1], addr
[2], addr
[3], addr
[4], addr
[5], addr
[6], addr
[7],
3369 mask
[0], mask
[1], mask
[2], mask
[3], mask
[4], mask
[5], mask
[6], mask
[7]
3372 return '%04x%04x%04x%04x%04x%04x%04x%04x%02x'.format(
3373 addr
[0], addr
[1], addr
[2], addr
[3], addr
[4], addr
[5], addr
[6], addr
[7],
3377 addr
= validation
.parseIPv4(m
[1]);
3378 mask
= m
[2] ? validation
.parseIPv4(m
[2]) : null;
3380 if (addr
&& mask
!= null)
3381 return '%03d%03d%03d%03d%03d%03d%03d%03d'.format(
3382 addr
[0], addr
[1], addr
[2], addr
[3],
3383 mask
[0], mask
[1], mask
[2], mask
[3]
3386 return '%03d%03d%03d%03d%02d'.format(
3387 addr
[0], addr
[1], addr
[2], addr
[3],
3392 m
= /^(?:(\d+)d )?(\d+)h (\d+)m (\d+)s$/.exec(value
);
3395 return '%05d%02d%02d%02d'.format(+m
[1], +m
[2], +m
[3], +m
[4]);
3397 m
= /^(\d+)\b(\D*)$/.exec(value
);
3400 return '%010d%s'.format(+m
[1], m
[2]);
3402 return String(value
);
3405 return String(value
).toLowerCase();
3411 return String(value
);
3416 getActiveSortState: function() {
3418 return this.sortState
;
3420 if (!this.options
.id
)
3423 var page
= document
.body
.getAttribute('data-page'),
3424 key
= page
+ '.' + this.options
.id
,
3425 state
= session
.getLocalData('tablesort');
3427 if (L
.isObject(state
) && Array
.isArray(state
[key
]))
3434 setActiveSortState: function(index
, descending
) {
3435 this.sortState
= [ index
, descending
];
3437 if (!this.options
.id
)
3440 var page
= document
.body
.getAttribute('data-page'),
3441 key
= page
+ '.' + this.options
.id
,
3442 state
= session
.getLocalData('tablesort');
3444 if (!L
.isObject(state
))
3447 state
[key
] = this.sortState
;
3449 session
.setLocalData('tablesort', state
);
3453 handleSort: function(ev
) {
3454 if (!ev
.target
.matches('th[data-sortable-row]'))
3457 var index
, direction
;
3459 this.node
.firstElementChild
.querySelectorAll('th, .th').forEach(function(th
, i
) {
3460 if (th
=== ev
.target
) {
3462 direction
= th
.getAttribute('data-sort-direction') == 'asc';
3466 this.setActiveSortState(index
, direction
);
3467 this.update(this.data
, this.placeholder
);
3477 * Provides high level UI helper functionality.
3478 * To import the class in views, use `'require ui'`, to import it in
3479 * external JavaScript, use `L.require("ui").then(...)`.
3481 var UI
= baseclass
.extend(/** @lends LuCI.ui.prototype */ {
3482 __init__: function() {
3483 modalDiv
= document
.body
.appendChild(
3485 id
: 'modal_overlay',
3487 keydown
: this.cancelModal
3496 tooltipDiv
= document
.body
.appendChild(
3497 dom
.create('div', { class: 'cbi-tooltip' }));
3499 /* setup old aliases */
3500 L
.showModal
= this.showModal
;
3501 L
.hideModal
= this.hideModal
;
3502 L
.showTooltip
= this.showTooltip
;
3503 L
.hideTooltip
= this.hideTooltip
;
3504 L
.itemlist
= this.itemlist
;
3506 document
.addEventListener('mouseover', this.showTooltip
.bind(this), true);
3507 document
.addEventListener('mouseout', this.hideTooltip
.bind(this), true);
3508 document
.addEventListener('focus', this.showTooltip
.bind(this), true);
3509 document
.addEventListener('blur', this.hideTooltip
.bind(this), true);
3511 document
.addEventListener('luci-loaded', this.tabs
.init
.bind(this.tabs
));
3512 document
.addEventListener('luci-loaded', this.changes
.init
.bind(this.changes
));
3513 document
.addEventListener('uci-loaded', this.changes
.init
.bind(this.changes
));
3517 * Display a modal overlay dialog with the specified contents.
3519 * The modal overlay dialog covers the current view preventing interaction
3520 * with the underlying view contents. Only one modal dialog instance can
3521 * be opened. Invoking showModal() while a modal dialog is already open will
3522 * replace the open dialog with a new one having the specified contents.
3524 * Additional CSS class names may be passed to influence the appearence of
3525 * the dialog. Valid values for the classes depend on the underlying theme.
3527 * @see LuCI.dom.content
3529 * @param {string} [title]
3530 * The title of the dialog. If `null`, no title element will be rendered.
3532 * @param {*} contents
3533 * The contents to add to the modal dialog. This should be a DOM node or
3534 * a document fragment in most cases. The value is passed as-is to the
3535 * `dom.content()` function - refer to its documentation for applicable
3538 * @param {...string} [classes]
3539 * A number of extra CSS class names which are set on the modal dialog
3543 * Returns a DOM Node representing the modal dialog element.
3545 showModal: function(title
, children
/* , ... */) {
3546 var dlg
= modalDiv
.firstElementChild
;
3548 dlg
.setAttribute('class', 'modal');
3550 for (var i
= 2; i
< arguments
.length
; i
++)
3551 dlg
.classList
.add(arguments
[i
]);
3553 dom
.content(dlg
, dom
.create('h4', {}, title
));
3554 dom
.append(dlg
, children
);
3556 document
.body
.classList
.add('modal-overlay-active');
3557 modalDiv
.scrollTop
= 0;
3564 * Close the open modal overlay dialog.
3566 * This function will close an open modal dialog and restore the normal view
3567 * behaviour. It has no effect if no modal dialog is currently open.
3569 * Note that this function is stand-alone, it does not rely on `this` and
3570 * will not invoke other class functions so it suitable to be used as event
3571 * handler as-is without the need to bind it first.
3573 hideModal: function() {
3574 document
.body
.classList
.remove('modal-overlay-active');
3579 cancelModal: function(ev
) {
3580 if (ev
.key
== 'Escape') {
3581 var btn
= modalDiv
.querySelector('.right > button, .right > .btn');
3589 showTooltip: function(ev
) {
3590 var target
= findParent(ev
.target
, '[data-tooltip]');
3595 if (tooltipTimeout
!== null) {
3596 window
.clearTimeout(tooltipTimeout
);
3597 tooltipTimeout
= null;
3600 var rect
= target
.getBoundingClientRect(),
3601 x
= rect
.left
+ window
.pageXOffset
,
3602 y
= rect
.top
+ rect
.height
+ window
.pageYOffset
,
3605 tooltipDiv
.className
= 'cbi-tooltip';
3606 tooltipDiv
.innerHTML
= '▲ ';
3607 tooltipDiv
.firstChild
.data
+= target
.getAttribute('data-tooltip');
3609 if (target
.hasAttribute('data-tooltip-style'))
3610 tooltipDiv
.classList
.add(target
.getAttribute('data-tooltip-style'));
3612 if ((y
+ tooltipDiv
.offsetHeight
) > (window
.innerHeight
+ window
.pageYOffset
))
3615 var dropdown
= target
.querySelector('ul.dropdown[style]:first-child');
3617 if (dropdown
&& dropdown
.style
.top
)
3621 y
-= (tooltipDiv
.offsetHeight
+ target
.offsetHeight
);
3622 tooltipDiv
.firstChild
.data
= '▼ ' + tooltipDiv
.firstChild
.data
.substr(2);
3625 tooltipDiv
.style
.top
= y
+ 'px';
3626 tooltipDiv
.style
.left
= x
+ 'px';
3627 tooltipDiv
.style
.opacity
= 1;
3629 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-open', {
3631 detail
: { target
: target
}
3636 hideTooltip: function(ev
) {
3637 if (ev
.target
=== tooltipDiv
|| ev
.relatedTarget
=== tooltipDiv
||
3638 tooltipDiv
.contains(ev
.target
) || tooltipDiv
.contains(ev
.relatedTarget
))
3641 if (tooltipTimeout
!== null) {
3642 window
.clearTimeout(tooltipTimeout
);
3643 tooltipTimeout
= null;
3646 tooltipDiv
.style
.opacity
= 0;
3647 tooltipTimeout
= window
.setTimeout(function() { tooltipDiv
.removeAttribute('style'); }, 250);
3649 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-close', { bubbles
: true }));
3653 * Add a notification banner at the top of the current view.
3655 * A notification banner is an alert message usually displayed at the
3656 * top of the current view, spanning the entire availibe width.
3657 * Notification banners will stay in place until dismissed by the user.
3658 * Multiple banners may be shown at the same time.
3660 * Additional CSS class names may be passed to influence the appearence of
3661 * the banner. Valid values for the classes depend on the underlying theme.
3663 * @see LuCI.dom.content
3665 * @param {string} [title]
3666 * The title of the notification banner. If `null`, no title element
3669 * @param {*} contents
3670 * The contents to add to the notification banner. This should be a DOM
3671 * node or a document fragment in most cases. The value is passed as-is
3672 * to the `dom.content()` function - refer to its documentation for
3673 * applicable values.
3675 * @param {...string} [classes]
3676 * A number of extra CSS class names which are set on the notification
3680 * Returns a DOM Node representing the notification banner element.
3682 addNotification: function(title
, children
/*, ... */) {
3683 var mc
= document
.querySelector('#maincontent') || document
.body
;
3684 var msg
= E('div', {
3685 'class': 'alert-message fade-in',
3686 'style': 'display:flex',
3687 'transitionend': function(ev
) {
3688 var node
= ev
.currentTarget
;
3689 if (node
.parentNode
&& node
.classList
.contains('fade-out'))
3690 node
.parentNode
.removeChild(node
);
3693 E('div', { 'style': 'flex:10' }),
3694 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3697 'style': 'margin-left:auto; margin-top:auto',
3698 'click': function(ev
) {
3699 dom
.parent(ev
.target
, '.alert-message').classList
.add('fade-out');
3702 }, [ _('Dismiss') ])
3707 dom
.append(msg
.firstElementChild
, E('h4', {}, title
));
3709 dom
.append(msg
.firstElementChild
, children
);
3711 for (var i
= 2; i
< arguments
.length
; i
++)
3712 msg
.classList
.add(arguments
[i
]);
3714 mc
.insertBefore(msg
, mc
.firstElementChild
);
3720 * Display or update an header area indicator.
3722 * An indicator is a small label displayed in the header area of the screen
3723 * providing few amounts of status information such as item counts or state
3724 * toggle indicators.
3726 * Multiple indicators may be shown at the same time and indicator labels
3727 * may be made clickable to display extended information or to initiate
3730 * Indicators can either use a default `active` or a less accented `inactive`
3731 * style which is useful for indicators representing state toggles.
3733 * @param {string} id
3734 * The ID of the indicator. If an indicator with the given ID already exists,
3735 * it is updated with the given label and style.
3737 * @param {string} label
3738 * The text to display in the indicator label.
3740 * @param {function} [handler]
3741 * A handler function to invoke when the indicator label is clicked/touched
3742 * by the user. If omitted, the indicator is not clickable/touchable.
3744 * Note that this parameter only applies to new indicators, when updating
3745 * existing labels it is ignored.
3747 * @param {string} [style=active]
3748 * The indicator style to use. May be either `active` or `inactive`.
3750 * @returns {boolean}
3751 * Returns `true` when the indicator has been updated or `false` when no
3752 * changes were made.
3754 showIndicator: function(id
, label
, handler
, style
) {
3755 if (indicatorDiv
== null) {
3756 indicatorDiv
= document
.body
.querySelector('#indicators');
3758 if (indicatorDiv
== null)
3762 var handlerFn
= (typeof(handler
) == 'function') ? handler
: null,
3763 indicatorElem
= indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
));
3765 if (indicatorElem
== null) {
3766 var beforeElem
= null;
3768 for (beforeElem
= indicatorDiv
.firstElementChild
;
3770 beforeElem
= beforeElem
.nextElementSibling
)
3771 if (beforeElem
.getAttribute('data-indicator') > id
)
3774 indicatorElem
= indicatorDiv
.insertBefore(E('span', {
3775 'data-indicator': id
,
3776 'data-clickable': handlerFn
? true : null,
3778 }, ['']), beforeElem
);
3781 if (label
== indicatorElem
.firstChild
.data
&& style
== indicatorElem
.getAttribute('data-style'))
3784 indicatorElem
.firstChild
.data
= label
;
3785 indicatorElem
.setAttribute('data-style', (style
== 'inactive') ? 'inactive' : 'active');
3790 * Remove an header area indicator.
3792 * This function removes the given indicator label from the header indicator
3793 * area. When the given indicator is not found, this function does nothing.
3795 * @param {string} id
3796 * The ID of the indicator to remove.
3798 * @returns {boolean}
3799 * Returns `true` when the indicator has been removed or `false` when the
3800 * requested indicator was not found.
3802 hideIndicator: function(id
) {
3803 var indicatorElem
= indicatorDiv
? indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
)) : null;
3805 if (indicatorElem
== null)
3808 indicatorDiv
.removeChild(indicatorElem
);
3813 * Formats a series of label/value pairs into list-like markup.
3815 * This function transforms a flat array of alternating label and value
3816 * elements into a list-like markup, using the values in `separators` as
3817 * separators and appends the resulting nodes to the given parent DOM node.
3819 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3820 * `<strong>` element and the value corresponding to the label are
3821 * subsequently wrapped into a `<span class="nowrap">` element.
3823 * The resulting `<span>` element tuples are joined by the given separators
3824 * to form the final markup which is appened to the given parent DOM node.
3826 * @param {Node} node
3827 * The parent DOM node to append the markup to. Any previous child elements
3830 * @param {Array<*>} items
3831 * An alternating array of labels and values. The label values will be
3832 * converted to plain strings, the values are used as-is and may be of
3833 * any type accepted by `LuCI.dom.content()`.
3835 * @param {*|Array<*>} [separators=[E('br')]]
3836 * A single value or an array of separator values to separate each
3837 * label/value pair with. The function will cycle through the separators
3838 * when joining the pairs. If omitted, the default separator is a sole HTML
3839 * `<br>` element. Separator values are used as-is and may be of any type
3840 * accepted by `LuCI.dom.content()`.
3843 * Returns the parent DOM node the formatted markup has been added to.
3845 itemlist: function(node
, items
, separators
) {
3848 if (!Array
.isArray(separators
))
3849 separators
= [ separators
|| E('br') ];
3851 for (var i
= 0; i
< items
.length
; i
+= 2) {
3852 if (items
[i
+1] !== null && items
[i
+1] !== undefined) {
3853 var sep
= separators
[(i
/2) % separators
.length
],
3856 children
.push(E('span', { class: 'nowrap' }, [
3857 items
[i
] ? E('strong', items
[i
] + ': ') : '',
3861 if ((i
+2) < items
.length
)
3862 children
.push(dom
.elem(sep
) ? sep
.cloneNode(true) : sep
);
3866 dom
.content(node
, children
);
3877 * The `tabs` class handles tab menu groups used throughout the view area.
3878 * It takes care of setting up tab groups, tracking their state and handling
3881 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3882 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3883 * external JavaScript, use `L.require("ui").then(...)` and access the
3884 * `tabs` property of the class instance value.
3886 tabs
: baseclass
.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3889 var groups
= [], prevGroup
= null, currGroup
= null;
3891 document
.querySelectorAll('[data-tab]').forEach(function(tab
) {
3892 var parent
= tab
.parentNode
;
3894 if (dom
.matches(tab
, 'li') && dom
.matches(parent
, 'ul.cbi-tabmenu'))
3897 if (!parent
.hasAttribute('data-tab-group'))
3898 parent
.setAttribute('data-tab-group', groups
.length
);
3900 currGroup
= +parent
.getAttribute('data-tab-group');
3902 if (currGroup
!== prevGroup
) {
3903 prevGroup
= currGroup
;
3905 if (!groups
[currGroup
])
3906 groups
[currGroup
] = [];
3909 groups
[currGroup
].push(tab
);
3912 for (var i
= 0; i
< groups
.length
; i
++)
3913 this.initTabGroup(groups
[i
]);
3915 document
.addEventListener('dependency-update', this.updateTabs
.bind(this));
3921 * Initializes a new tab group from the given tab pane collection.
3923 * This function cycles through the given tab pane DOM nodes, extracts
3924 * their tab IDs, titles and active states, renders a corresponding
3925 * tab menu and prepends it to the tab panes common parent DOM node.
3927 * The tab menu labels will be set to the value of the `data-tab-title`
3928 * attribute of each corresponding pane. The last pane with the
3929 * `data-tab-active` attribute set to `true` will be selected by default.
3931 * If no pane is marked as active, the first one will be preselected.
3934 * @memberof LuCI.ui.tabs
3935 * @param {Array<Node>|NodeList} panes
3936 * A collection of tab panes to build a tab group menu for. May be a
3937 * plain array of DOM nodes or a NodeList collection, such as the result
3938 * of a `querySelectorAll()` call or the `.childNodes` property of a
3941 initTabGroup: function(panes
) {
3942 if (typeof(panes
) != 'object' || !('length' in panes
) || panes
.length
=== 0)
3945 var menu
= E('ul', { 'class': 'cbi-tabmenu' }),
3946 group
= panes
[0].parentNode
,
3947 groupId
= +group
.getAttribute('data-tab-group'),
3950 if (group
.getAttribute('data-initialized') === 'true')
3953 for (var i
= 0, pane
; pane
= panes
[i
]; i
++) {
3954 var name
= pane
.getAttribute('data-tab'),
3955 title
= pane
.getAttribute('data-tab-title'),
3956 active
= pane
.getAttribute('data-tab-active') === 'true';
3958 menu
.appendChild(E('li', {
3959 'style': this.isEmptyPane(pane
) ? 'display:none' : null,
3960 'class': active
? 'cbi-tab' : 'cbi-tab-disabled',
3964 'click': this.switchTab
.bind(this)
3971 group
.parentNode
.insertBefore(menu
, group
);
3972 group
.setAttribute('data-initialized', true);
3974 if (selected
=== null) {
3975 selected
= this.getActiveTabId(panes
[0]);
3977 if (selected
< 0 || selected
>= panes
.length
|| this.isEmptyPane(panes
[selected
])) {
3978 for (var i
= 0; i
< panes
.length
; i
++) {
3979 if (!this.isEmptyPane(panes
[i
])) {
3986 menu
.childNodes
[selected
].classList
.add('cbi-tab');
3987 menu
.childNodes
[selected
].classList
.remove('cbi-tab-disabled');
3988 panes
[selected
].setAttribute('data-tab-active', 'true');
3990 this.setActiveTabId(panes
[selected
], selected
);
3993 requestAnimationFrame(L
.bind(function(pane
) {
3994 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', {
3995 detail
: { tab
: pane
.getAttribute('data-tab') }
3997 }, this, panes
[selected
]));
3999 this.updateTabs(group
);
4003 * Checks whether the given tab pane node is empty.
4006 * @memberof LuCI.ui.tabs
4007 * @param {Node} pane
4008 * The tab pane to check.
4010 * @returns {boolean}
4011 * Returns `true` if the pane is empty, else `false`.
4013 isEmptyPane: function(pane
) {
4014 return dom
.isEmpty(pane
, function(n
) { return n
.classList
.contains('cbi-tab-descr') });
4018 getPathForPane: function(pane
) {
4019 var path
= [], node
= null;
4021 for (node
= pane
? pane
.parentNode
: null;
4022 node
!= null && node
.hasAttribute
!= null;
4023 node
= node
.parentNode
)
4025 if (node
.hasAttribute('data-tab'))
4026 path
.unshift(node
.getAttribute('data-tab'));
4027 else if (node
.hasAttribute('data-section-id'))
4028 path
.unshift(node
.getAttribute('data-section-id'));
4031 return path
.join('/');
4035 getActiveTabState: function() {
4036 var page
= document
.body
.getAttribute('data-page'),
4037 state
= session
.getLocalData('tab');
4039 if (L
.isObject(state
) && state
.page
=== page
&& L
.isObject(state
.paths
))
4042 session
.setLocalData('tab', null);
4044 return { page
: page
, paths
: {} };
4048 getActiveTabId: function(pane
) {
4049 var path
= this.getPathForPane(pane
);
4050 return +this.getActiveTabState().paths
[path
] || 0;
4054 setActiveTabId: function(pane
, tabIndex
) {
4055 var path
= this.getPathForPane(pane
),
4056 state
= this.getActiveTabState();
4058 state
.paths
[path
] = tabIndex
;
4060 return session
.setLocalData('tab', state
);
4064 updateTabs: function(ev
, root
) {
4065 (root
|| document
).querySelectorAll('[data-tab-title]').forEach(L
.bind(function(pane
) {
4066 var menu
= pane
.parentNode
.previousElementSibling
,
4067 tab
= menu
? menu
.querySelector('[data-tab="%s"]'.format(pane
.getAttribute('data-tab'))) : null,
4068 n_errors
= pane
.querySelectorAll('.cbi-input-invalid').length
;
4073 if (this.isEmptyPane(pane
)) {
4074 tab
.style
.display
= 'none';
4075 tab
.classList
.remove('flash');
4077 else if (tab
.style
.display
=== 'none') {
4078 tab
.style
.display
= '';
4079 requestAnimationFrame(function() { tab
.classList
.add('flash') });
4083 tab
.setAttribute('data-errors', n_errors
);
4084 tab
.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors
));
4085 tab
.setAttribute('data-tooltip-style', 'error');
4088 tab
.removeAttribute('data-errors');
4089 tab
.removeAttribute('data-tooltip');
4095 switchTab: function(ev
) {
4096 var tab
= ev
.target
.parentNode
,
4097 name
= tab
.getAttribute('data-tab'),
4098 menu
= tab
.parentNode
,
4099 group
= menu
.nextElementSibling
,
4100 groupId
= +group
.getAttribute('data-tab-group'),
4103 ev
.preventDefault();
4105 if (!tab
.classList
.contains('cbi-tab-disabled'))
4108 menu
.querySelectorAll('[data-tab]').forEach(function(tab
) {
4109 tab
.classList
.remove('cbi-tab');
4110 tab
.classList
.remove('cbi-tab-disabled');
4112 tab
.getAttribute('data-tab') === name
? 'cbi-tab' : 'cbi-tab-disabled');
4115 group
.childNodes
.forEach(function(pane
) {
4116 if (dom
.matches(pane
, '[data-tab]')) {
4117 if (pane
.getAttribute('data-tab') === name
) {
4118 pane
.setAttribute('data-tab-active', 'true');
4119 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', { detail
: { tab
: name
} }));
4120 UI
.prototype.tabs
.setActiveTabId(pane
, index
);
4123 pane
.setAttribute('data-tab-active', 'false');
4133 * @typedef {Object} FileUploadReply
4136 * @property {string} name - Name of the uploaded file without directory components
4137 * @property {number} size - Size of the uploaded file in bytes
4138 * @property {string} checksum - The MD5 checksum of the received file data
4139 * @property {string} sha256sum - The SHA256 checksum of the received file data
4143 * Display a modal file upload prompt.
4145 * This function opens a modal dialog prompting the user to select and
4146 * upload a file to a predefined remote destination path.
4148 * @param {string} path
4149 * The remote file path to upload the local file to.
4151 * @param {Node} [progessStatusNode]
4152 * An optional DOM text node whose content text is set to the progress
4153 * percentage value during file upload.
4155 * @returns {Promise<LuCI.ui.FileUploadReply>}
4156 * Returns a promise resolving to a file upload status object on success
4157 * or rejecting with an error in case the upload failed or has been
4158 * cancelled by the user.
4160 uploadFile: function(path
, progressStatusNode
) {
4161 return new Promise(function(resolveFn
, rejectFn
) {
4162 UI
.prototype.showModal(_('Uploading file…'), [
4163 E('p', _('Please select the file to upload.')),
4164 E('div', { 'style': 'display:flex' }, [
4165 E('div', { 'class': 'left', 'style': 'flex:1' }, [
4168 style
: 'display:none',
4169 change: function(ev
) {
4170 var modal
= dom
.parent(ev
.target
, '.modal'),
4171 body
= modal
.querySelector('p'),
4172 upload
= modal
.querySelector('.cbi-button-action.important'),
4173 file
= ev
.currentTarget
.files
[0];
4180 E('li', {}, [ '%s: %s'.format(_('Name'), file
.name
.replace(/^.*[\\\/]/, '')) ]),
4181 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file
.size
) ])
4185 upload
.disabled
= false;
4191 'click': function(ev
) {
4192 ev
.target
.previousElementSibling
.click();
4194 }, [ _('Browse…') ])
4196 E('div', { 'class': 'right', 'style': 'flex:1' }, [
4199 'click': function() {
4200 UI
.prototype.hideModal();
4201 rejectFn(new Error(_('Upload has been cancelled')));
4203 }, [ _('Cancel') ]),
4206 'class': 'btn cbi-button-action important',
4208 'click': function(ev
) {
4209 var input
= dom
.parent(ev
.target
, '.modal').querySelector('input[type="file"]');
4211 if (!input
.files
[0])
4214 var progress
= E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
4216 UI
.prototype.showModal(_('Uploading file…'), [ progress
]);
4218 var data
= new FormData();
4220 data
.append('sessionid', rpc
.getSessionID());
4221 data
.append('filename', path
);
4222 data
.append('filedata', input
.files
[0]);
4224 var filename
= input
.files
[0].name
;
4226 request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
4228 progress: function(pev
) {
4229 var percent
= (pev
.loaded
/ pev
.total
) * 100;
4231 if (progressStatusNode
)
4232 progressStatusNode
.data
= '%.2f%%'.format(percent
);
4234 progress
.setAttribute('title', '%.2f%%'.format(percent
));
4235 progress
.firstElementChild
.style
.width
= '%.2f%%'.format(percent
);
4237 }).then(function(res
) {
4238 var reply
= res
.json();
4240 UI
.prototype.hideModal();
4242 if (L
.isObject(reply
) && reply
.failure
) {
4243 UI
.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply
.message
)));
4244 rejectFn(new Error(reply
.failure
));
4247 reply
.name
= filename
;
4251 UI
.prototype.hideModal();
4263 * Perform a device connectivity test.
4265 * Attempt to fetch a well known ressource from the remote device via HTTP
4266 * in order to test connectivity. This function is mainly useful to wait
4267 * for the router to come back online after a reboot or reconfiguration.
4269 * @param {string} [proto=http]
4270 * The protocol to use for fetching the resource. May be either `http`
4271 * (the default) or `https`.
4273 * @param {string} [host=window.location.host]
4274 * Override the host address to probe. By default the current host as seen
4275 * in the address bar is probed.
4277 * @returns {Promise<Event>}
4278 * Returns a promise resolving to a `load` event in case the device is
4279 * reachable or rejecting with an `error` event in case it is not reachable
4280 * or rejecting with `null` when the connectivity check timed out.
4282 pingDevice: function(proto
, ipaddr
) {
4283 var target
= '%s://%s%s?%s'.format(proto
|| 'http', ipaddr
|| window
.location
.host
, L
.resource('icons/loading.gif'), Math
.random());
4285 return new Promise(function(resolveFn
, rejectFn
) {
4286 var img
= new Image();
4288 img
.onload
= resolveFn
;
4289 img
.onerror
= rejectFn
;
4291 window
.setTimeout(rejectFn
, 1000);
4298 * Wait for device to come back online and reconnect to it.
4300 * Poll each given hostname or IP address and navigate to it as soon as
4301 * one of the addresses becomes reachable.
4303 * @param {...string} [hosts=[window.location.host]]
4304 * The list of IP addresses and host names to check for reachability.
4305 * If omitted, the current value of `window.location.host` is used by
4308 awaitReconnect: function(/* ... */) {
4309 var ipaddrs
= arguments
.length
? arguments
: [ window
.location
.host
];
4311 window
.setTimeout(L
.bind(function() {
4312 poll
.add(L
.bind(function() {
4313 var tasks
= [], reachable
= false;
4315 for (var i
= 0; i
< 2; i
++)
4316 for (var j
= 0; j
< ipaddrs
.length
; j
++)
4317 tasks
.push(this.pingDevice(i
? 'https' : 'http', ipaddrs
[j
])
4318 .then(function(ev
) { reachable
= ev
.target
.src
.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
4320 return Promise
.all(tasks
).then(function() {
4323 window
.location
= reachable
;
4336 * The `changes` class encapsulates logic for visualizing, applying,
4337 * confirming and reverting staged UCI changesets.
4339 * This class is automatically instantiated as part of `LuCI.ui`. To use it
4340 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
4341 * external JavaScript, use `L.require("ui").then(...)` and access the
4342 * `changes` property of the class instance value.
4344 changes
: baseclass
.singleton(/* @lends LuCI.ui.changes.prototype */ {
4346 if (!L
.env
.sessionid
)
4349 return uci
.changes().then(L
.bind(this.renderChangeIndicator
, this));
4353 * Set the change count indicator.
4355 * This function updates or hides the UCI change count indicator,
4356 * depending on the passed change count. When the count is greater
4357 * than 0, the change indicator is displayed or updated, otherwise it
4361 * @memberof LuCI.ui.changes
4362 * @param {number} numChanges
4363 * The number of changes to indicate.
4365 setIndicator: function(n
) {
4367 UI
.prototype.showIndicator('uci-changes',
4368 '%s: %d'.format(_('Unsaved Changes'), n
),
4369 L
.bind(this.displayChanges
, this));
4372 UI
.prototype.hideIndicator('uci-changes');
4377 * Update the change count indicator.
4379 * This function updates the UCI change count indicator from the given
4380 * UCI changeset structure.
4383 * @memberof LuCI.ui.changes
4384 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
4385 * The UCI changeset to count.
4387 renderChangeIndicator: function(changes
) {
4390 for (var config
in changes
)
4391 if (changes
.hasOwnProperty(config
))
4392 n_changes
+= changes
[config
].length
;
4394 this.changes
= changes
;
4395 this.setIndicator(n_changes
);
4400 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
4401 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
4402 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
4403 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
4404 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
4405 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
4406 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
4407 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
4408 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
4409 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
4413 * Display the current changelog.
4415 * Open a modal dialog visualizing the currently staged UCI changes
4416 * and offer options to revert or apply the shown changes.
4419 * @memberof LuCI.ui.changes
4421 displayChanges: function() {
4422 var list
= E('div', { 'class': 'uci-change-list' }),
4423 dlg
= UI
.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
4424 E('div', { 'class': 'cbi-section' }, [
4425 E('strong', _('Legend:')),
4426 E('div', { 'class': 'uci-change-legend' }, [
4427 E('div', { 'class': 'uci-change-legend-label' }, [
4428 E('ins', ' '), ' ', _('Section added') ]),
4429 E('div', { 'class': 'uci-change-legend-label' }, [
4430 E('del', ' '), ' ', _('Section removed') ]),
4431 E('div', { 'class': 'uci-change-legend-label' }, [
4432 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
4433 E('div', { 'class': 'uci-change-legend-label' }, [
4434 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
4436 E('div', { 'class': 'right' }, [
4439 'click': UI
.prototype.hideModal
4440 }, [ _('Close') ]), ' ',
4441 new UIComboButton('0', {
4442 0: [ _('Save & Apply') ],
4443 1: [ _('Apply unchecked') ]
4446 0: 'btn cbi-button cbi-button-positive important',
4447 1: 'btn cbi-button cbi-button-negative important'
4449 click
: L
.bind(function(ev
, mode
) { this.apply(mode
== '0') }, this)
4452 'class': 'cbi-button cbi-button-reset',
4453 'click': L
.bind(this.revert
, this)
4454 }, [ _('Revert') ])])])
4457 for (var config
in this.changes
) {
4458 if (!this.changes
.hasOwnProperty(config
))
4461 list
.appendChild(E('h5', '# /etc/config/%s'.format(config
)));
4463 for (var i
= 0, added
= null; i
< this.changes
[config
].length
; i
++) {
4464 var chg
= this.changes
[config
][i
],
4465 tpl
= this.changeTemplates
['%s-%d'.format(chg
[0], chg
.length
)];
4467 list
.appendChild(E(tpl
.replace(/%([01234])/g, function(m0
, m1
) {
4473 if (added
!= null && chg
[1] == added
[0])
4474 return '@' + added
[1] + '[-1]';
4479 return "'%h'".format(chg
[3].replace(/'/g, "'\"'\"'"));
4486 if (chg[0] == 'add')
4487 added = [ chg[1], chg[2] ];
4491 list.appendChild(E('br'));
4492 dlg.classList.add('uci-dialog');
4496 displayStatus: function(type, content) {
4498 var message = UI.prototype.showModal('', '');
4500 message.classList.add('alert-message');
4501 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4504 dom.content(message, content);
4506 if (!this.was_polling) {
4507 this.was_polling = request.poll.active();
4508 request.poll.stop();
4512 UI.prototype.hideModal();
4514 if (this.was_polling)
4515 request.poll.start();
4520 checkConnectivityAffected: function() {
4521 return L.resolveDefault(fs.exec_direct('/usr/libexec/luci-peeraddr', null, 'json')).then(L.bind(function(info) {
4522 if (L.isObject(info) && Array.isArray(info.inbound_interfaces)) {
4523 for (var i = 0; i < info.inbound_interfaces.length; i++) {
4524 var iif = info.inbound_interfaces[i];
4526 for (var j = 0; this.changes && this.changes.network && j < this.changes.network.length; j++) {
4527 var chg = this.changes.network[j];
4529 if (chg[0] == 'set' && chg[1] == iif &&
4530 ((chg[2] == 'disabled' && chg[3] == '1') || chg[2] == 'proto' || chg[2] == 'ipaddr' || chg[2] == 'netmask'))
4541 rollback: function(checked) {
4543 this.displayStatus('warning spinning',
4544 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4545 .format(L.env.apply_rollback)));
4547 var call = function(r, data, duration) {
4548 if (r.status === 204) {
4549 UI.prototype.changes.displayStatus('warning', [
4550 E('h4', _('Configuration changes have been rolled back!')),
4551 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)),
4552 E('div', { 'class': 'right' }, [
4555 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4556 }, [ _('Dismiss') ]), ' ',
4558 'class': 'btn cbi-button-action important',
4559 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4560 }, [ _('Revert changes') ]), ' ',
4562 'class': 'btn cbi-button-negative important',
4563 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4564 }, [ _('Apply unchecked') ])
4571 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4572 window.setTimeout(function() {
4573 request.request(L.url('admin/uci/confirm'), {
4575 timeout: L.env.apply_timeout * 1000,
4576 query: { sid: L.env.sessionid, token: L.env.token }
4577 }).then(call, call.bind(null, { status: 0 }, null, 0));
4581 call({ status: 0 });
4584 this.displayStatus('warning', [
4585 E('h4', _('Device unreachable!')),
4586 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.'))
4592 confirm: function(checked, deadline, override_token) {
4594 var ts = Date.now();
4596 this.displayStatus('notice');
4599 this.confirm_auth = { token: override_token };
4601 var call = function(r, data, duration) {
4602 if (Date.now() >= deadline) {
4603 window.clearTimeout(tt);
4604 UI.prototype.changes.rollback(checked);
4607 else if (r && (r.status === 200 || r.status === 204)) {
4608 document.dispatchEvent(new CustomEvent('uci-applied'));
4610 UI.prototype.changes.setIndicator(0);
4611 UI.prototype.changes.displayStatus('notice',
4612 E('p', _('Configuration changes applied.')));
4614 window.clearTimeout(tt);
4615 window.setTimeout(function() {
4616 //UI.prototype.changes.displayStatus(false);
4617 window.location = window.location.href.split('#')[0];
4618 }, L.env.apply_display * 1000);
4623 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4624 window.setTimeout(function() {
4625 request.request(L.url('admin/uci/confirm'), {
4627 timeout: L.env.apply_timeout * 1000,
4628 query: UI.prototype.changes.confirm_auth
4629 }).then(call, call);
4633 var tick = function() {
4634 var now = Date.now();
4636 UI.prototype.changes.displayStatus('notice spinning',
4637 E('p', _('Applying configuration changes… %ds')
4638 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4640 if (now >= deadline)
4643 tt = window.setTimeout(tick, 1000 - (now - ts));
4649 /* wait a few seconds for the settings to become effective */
4650 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4654 * Apply the staged configuration changes.
4656 * Start applying staged configuration changes and open a modal dialog
4657 * with a progress indication to prevent interaction with the view
4658 * during the apply process. The modal dialog will be automatically
4659 * closed and the current view reloaded once the apply process is
4663 * @memberof LuCI.ui.changes
4664 * @param {boolean} [checked=false]
4665 * Whether to perform a checked (`true`) configuration apply or an
4666 * unchecked (`false`) one.
4668 * In case of a checked apply, the configuration changes must be
4669 * confirmed within a specific time interval, otherwise the device
4670 * will begin to roll back the changes in order to restore the previous
4673 apply: function(checked) {
4674 this.displayStatus('notice spinning',
4675 E('p', _('Starting configuration apply…')));
4677 (new Promise(function(resolveFn, rejectFn) {
4679 return resolveFn(false);
4681 UI.prototype.changes.checkConnectivityAffected().then(function(affected) {
4683 return resolveFn(true);
4685 UI.prototype.changes.displayStatus('warning', [
4686 E('h4', _('Connectivity change')),
4687 E('p', _('The network access to this device could be interrupted by changing settings of the "%h
" interface.').format(affected)),
4688 E('p', _('If the IP address used to access LuCI changes, a <strong>manual reconnect to the new IP</strong> is required within %d seconds to confirm the settings, otherwise modifications will be reverted.').format(L.env.apply_rollback)),
4689 E('div', { 'class': 'right' }, [
4693 }, [ _('Cancel') ]), ' ',
4695 'class': 'btn cbi-button-action important',
4696 'click': resolveFn.bind(null, true)
4697 }, [ _('Apply with revert after connectivity loss') ]), ' ',
4699 'class': 'btn cbi-button-negative important',
4700 'click': resolveFn.bind(null, false)
4701 }, [ _('Apply and keep settings') ])
4705 })).then(function(checked) {
4706 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4708 query: { sid: L.env.sessionid, token: L.env.token }
4709 }).then(function(r) {
4710 if (r.status === (checked ? 200 : 204)) {
4711 var tok = null; try { tok = r.json(); } catch(e) {}
4712 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4713 UI.prototype.changes.confirm_auth = tok;
4715 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4717 else if (checked && r.status === 204) {
4718 UI.prototype.changes.displayStatus('notice',
4719 E('p', _('There are no changes to apply')));
4721 window.setTimeout(function() {
4722 UI.prototype.changes.displayStatus(false);
4723 }, L.env.apply_display * 1000);
4726 UI.prototype.changes.displayStatus('warning',
4727 E('p', _('Apply request failed with status <code>%h</code>')
4728 .format(r.responseText || r.statusText || r.status)));
4730 window.setTimeout(function() {
4731 UI.prototype.changes.displayStatus(false);
4732 }, L.env.apply_display * 1000);
4735 }, this.displayStatus.bind(this, false));
4739 * Revert the staged configuration changes.
4741 * Start reverting staged configuration changes and open a modal dialog
4742 * with a progress indication to prevent interaction with the view
4743 * during the revert process. The modal dialog will be automatically
4744 * closed and the current view reloaded once the revert process is
4748 * @memberof LuCI.ui.changes
4750 revert: function() {
4751 this.displayStatus('notice spinning',
4752 E('p', _('Reverting configuration…')));
4754 request.request(L.url('admin/uci/revert'), {
4756 query: { sid: L.env.sessionid, token: L.env.token }
4757 }).then(function(r) {
4758 if (r.status === 200) {
4759 document.dispatchEvent(new CustomEvent('uci-reverted'));
4761 UI.prototype.changes.setIndicator(0);
4762 UI.prototype.changes.displayStatus('notice',
4763 E('p', _('Changes have been reverted.')));
4765 window.setTimeout(function() {
4766 //UI.prototype.changes.displayStatus(false);
4767 window.location = window.location.href.split('#')[0];
4768 }, L.env.apply_display * 1000);
4771 UI.prototype.changes.displayStatus('warning',
4772 E('p', _('Revert request failed with status <code>%h</code>')
4773 .format(r.statusText || r.status)));
4775 window.setTimeout(function() {
4776 UI.prototype.changes.displayStatus(false);
4777 }, L.env.apply_display * 1000);
4784 * Add validation constraints to an input element.
4786 * Compile the given type expression and optional validator function into
4787 * a validation function and bind it to the specified input element events.
4789 * @param {Node} field
4790 * The DOM input element node to bind the validation constraints to.
4792 * @param {string} type
4793 * The datatype specification to describe validation constraints.
4794 * Refer to the `LuCI.validation` class documentation for details.
4796 * @param {boolean} [optional=false]
4797 * Specifies whether empty values are allowed (`true`) or not (`false`).
4798 * If an input element is not marked optional it must not be empty,
4799 * otherwise it will be marked as invalid.
4801 * @param {function} [vfunc]
4802 * Specifies a custom validation function which is invoked after the
4803 * other validation constraints are applied. The validation must return
4804 * `true` to accept the passed value. Any other return type is converted
4805 * to a string and treated as validation error message.
4807 * @param {...string} [events=blur, keyup]
4808 * The list of events to bind. Each received event will trigger a field
4809 * validation. If omitted, the `keyup` and `blur` events are bound by
4812 * @returns {function}
4813 * Returns the compiled validator function which can be used to manually
4814 * trigger field validation or to bind it to further events.
4816 * @see LuCI.validation
4818 addValidator: function(field, type, optional, vfunc /*, ... */) {
4822 var events = this.varargs(arguments, 3);
4823 if (events.length == 0)
4824 events.push('blur', 'keyup');
4827 var cbiValidator = validation.create(field, type, optional, vfunc),
4828 validatorFn = cbiValidator.validate.bind(cbiValidator);
4830 for (var i = 0; i < events.length; i++)
4831 field.addEventListener(events[i], validatorFn);
4841 * Create a pre-bound event handler function.
4843 * Generate and bind a function suitable for use in event handlers. The
4844 * generated function automatically disables the event source element
4845 * and adds an active indication to it by adding appropriate CSS classes.
4847 * It will also await any promises returned by the wrapped function and
4848 * re-enable the source element after the promises ran to completion.
4851 * The `this` context to use for the wrapped function.
4853 * @param {function|string} fn
4854 * Specifies the function to wrap. In case of a function value, the
4855 * function is used as-is. If a string is specified instead, it is looked
4856 * up in `ctx` to obtain the function to wrap. In both cases the bound
4857 * function will be invoked with `ctx` as `this` context
4859 * @param {...*} extra_args
4860 * Any further parameter as passed as-is to the bound event handler
4861 * function in the same order as passed to `createHandlerFn()`.
4863 * @returns {function|null}
4864 * Returns the pre-bound handler function which is suitable to be passed
4865 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4866 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4867 * valid function value.
4869 createHandlerFn: function(ctx, fn /*, ... */) {
4870 if (typeof(fn) == 'string')
4873 if (typeof(fn) != 'function')
4876 var arg_offset = arguments.length - 2;
4878 return Function.prototype.bind.apply(function() {
4879 var t = arguments[arg_offset].currentTarget;
4881 t.classList.add('spinning');
4887 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4888 t.classList.remove('spinning');
4891 }, this.varargs(arguments, 2, ctx));
4895 * Load specified view class path and set it up.
4897 * Transforms the given view path into a class name, requires it
4898 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4899 * resulting class instance is a descendant of
4900 * [LuCI.view]{@link LuCI.view}.
4902 * By instantiating the view class, its corresponding contents are
4903 * rendered and included into the view area. Any runtime errors are
4904 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4906 * @param {string} path
4907 * The view path to render.
4909 * @returns {Promise<LuCI.view>}
4910 * Returns a promise resolving to the loaded view instance.
4912 instantiateView: function(path) {
4913 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4915 return L.require(className).then(function(view) {
4916 if (!(view instanceof View))
4917 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4920 }).catch(function(err) {
4921 dom.content(document.querySelector('#view'), null);
4930 AbstractElement: UIElement,
4933 Textfield: UITextfield,
4934 Textarea: UITextarea,
4935 Checkbox: UICheckbox,
4937 Dropdown: UIDropdown,
4938 DynamicList: UIDynamicList,
4939 Combobox: UICombobox,
4940 ComboButton: UIComboButton,
4941 Hiddenfield: UIHiddenfield,
4942 FileUpload: UIFileUpload