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 implicitly 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 unchanged. 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 * Set up 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 * Set up 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, set up 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 implicitly 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 implicitly 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 implicitly 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 implicitly 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 {"select"|"individual"} [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 implicitly 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
);
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
);
1608 if (!new_item
.hasAttribute('unselectable'))
1609 new_item
.setAttribute('tabindex', 0);
1615 createItems: function(sb
, value
) {
1617 val
= (value
|| '').trim(),
1618 ul
= sb
.querySelector('ul');
1620 if (!sbox
.options
.multiple
)
1621 val
= val
.length
? [ val
] : [];
1623 val
= val
.length
? val
.split(/\s+/) : [];
1625 val
.forEach(function(item
) {
1626 var new_item
= null;
1628 ul
.childNodes
.forEach(function(li
) {
1629 if (li
.getAttribute
&& li
.getAttribute('data-value') === item
)
1634 new_item
= sbox
.createChoiceElement(sb
, item
);
1636 if (!sbox
.options
.multiple
) {
1637 var old
= ul
.querySelector('li[created]');
1639 ul
.removeChild(old
);
1641 new_item
.setAttribute('created', '');
1644 new_item
= ul
.insertBefore(new_item
, ul
.lastElementChild
);
1647 sbox
.toggleItem(sb
, new_item
, true);
1648 sbox
.setFocus(sb
, new_item
, true);
1653 * Remove all existing choices from the dropdown menu.
1655 * This function removes all preexisting dropdown choices from the widget,
1656 * keeping only choices currently being selected unless `reset_values` is
1657 * given, in which case all choices and deselected and removed.
1660 * @memberof LuCI.ui.Dropdown
1661 * @param {boolean} [reset_value=false]
1662 * If set to `true`, deselect and remove selected choices as well instead
1665 clearChoices: function(reset_value
) {
1666 var ul
= this.node
.querySelector('ul'),
1667 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [],
1668 len
= lis
.length
- (this.options
.create
? 1 : 0),
1669 val
= reset_value
? null : this.getValue();
1671 for (var i
= 0; i
< len
; i
++) {
1672 var lival
= lis
[i
].getAttribute('data-value');
1674 (!this.options
.multiple
&& val
!= lival
) ||
1675 (this.options
.multiple
&& val
.indexOf(lival
) == -1))
1676 ul
.removeChild(lis
[i
]);
1680 this.setValues(this.node
, {});
1684 * Add new choices to the dropdown menu.
1686 * This function adds further choices to an existing dropdown menu,
1687 * ignoring choice values which are already present.
1690 * @memberof LuCI.ui.Dropdown
1691 * @param {string[]} values
1692 * The choice values to add to the dropdown widget.
1694 * @param {Object<string, *>} labels
1695 * The choice label values to use when adding dropdown choices. If no
1696 * label is found for a particular choice value, the value itself is used
1697 * as label text. Choice labels may be any valid value accepted by
1698 * {@link LuCI.dom#content}.
1700 addChoices: function(values
, labels
) {
1702 ul
= sb
.querySelector('ul'),
1703 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [];
1705 if (!Array
.isArray(values
))
1706 values
= L
.toArray(values
);
1708 if (!L
.isObject(labels
))
1711 for (var i
= 0; i
< values
.length
; i
++) {
1714 for (var j
= 0; j
< lis
.length
; j
++) {
1715 if (lis
[j
].getAttribute('data-value') === values
[i
]) {
1725 this.createChoiceElement(sb
, values
[i
], labels
[values
[i
]]),
1726 ul
.lastElementChild
);
1731 * Close all open dropdown widgets in the current document.
1733 closeAllDropdowns: function() {
1734 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1735 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1740 handleClick: function(ev
) {
1741 var sb
= ev
.currentTarget
;
1743 if (!sb
.hasAttribute('open')) {
1744 if (!matchesElem(ev
.target
, 'input'))
1745 this.openDropdown(sb
);
1748 var li
= findParent(ev
.target
, 'li');
1749 if (li
&& li
.parentNode
.classList
.contains('dropdown'))
1750 this.toggleItem(sb
, li
);
1751 else if (li
&& li
.parentNode
.classList
.contains('preview'))
1752 this.closeDropdown(sb
);
1753 else if (matchesElem(ev
.target
, 'span.open, span.more'))
1754 this.closeDropdown(sb
);
1757 ev
.preventDefault();
1758 ev
.stopPropagation();
1762 handleKeydown: function(ev
) {
1763 var sb
= ev
.currentTarget
,
1764 ul
= sb
.querySelector('ul.dropdown');
1766 if (matchesElem(ev
.target
, 'input'))
1769 if (!sb
.hasAttribute('open')) {
1770 switch (ev
.keyCode
) {
1775 this.openDropdown(sb
);
1776 ev
.preventDefault();
1780 var active
= findParent(document
.activeElement
, 'li');
1782 switch (ev
.keyCode
) {
1784 this.closeDropdown(sb
);
1785 ev
.stopPropagation();
1790 if (!active
.hasAttribute('selected'))
1791 this.toggleItem(sb
, active
);
1792 this.closeDropdown(sb
);
1793 ev
.preventDefault();
1799 this.toggleItem(sb
, active
);
1800 ev
.preventDefault();
1805 if (active
&& active
.previousElementSibling
) {
1806 this.setFocus(sb
, active
.previousElementSibling
);
1807 ev
.preventDefault();
1809 else if (document
.activeElement
=== ul
) {
1810 this.setFocus(sb
, ul
.lastElementChild
);
1811 ev
.preventDefault();
1816 if (active
&& active
.nextElementSibling
) {
1817 var li
= active
.nextElementSibling
;
1818 this.setFocus(sb
, li
);
1819 if (this.options
.create
&& li
== li
.parentNode
.lastElementChild
) {
1820 var input
= li
.querySelector('input:not([type="hidden"]):not([type="checkbox"]');
1821 if (input
) input
.focus();
1823 ev
.preventDefault();
1825 else if (document
.activeElement
=== ul
) {
1826 this.setFocus(sb
, ul
.firstElementChild
);
1827 ev
.preventDefault();
1835 handleDropdownClose: function(ev
) {
1836 var sb
= ev
.currentTarget
;
1838 this.closeDropdown(sb
, true);
1842 handleDropdownSelect: function(ev
) {
1843 var sb
= ev
.currentTarget
,
1844 li
= findParent(ev
.target
, 'li');
1849 this.toggleItem(sb
, li
);
1850 this.closeDropdown(sb
, true);
1854 handleFocus: function(ev
) {
1855 var sb
= ev
.currentTarget
;
1857 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1858 if (s
!== sb
|| sb
.hasAttribute('open'))
1859 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1864 handleCanaryFocus: function(ev
) {
1865 this.closeDropdown(ev
.currentTarget
.parentNode
);
1869 handleCreateKeydown: function(ev
) {
1870 var input
= ev
.currentTarget
,
1871 li
= findParent(input
, 'li'),
1872 sb
= findParent(li
, '.cbi-dropdown');
1874 switch (ev
.keyCode
) {
1876 ev
.preventDefault();
1878 if (input
.classList
.contains('cbi-input-invalid'))
1881 this.handleCreateBlur(ev
);
1882 this.createItems(sb
, input
.value
);
1887 this.handleCreateBlur(ev
);
1888 this.closeDropdown(sb
);
1889 ev
.stopPropagation();
1894 if (li
.previousElementSibling
) {
1895 this.handleCreateBlur(ev
);
1896 this.setFocus(sb
, li
.previousElementSibling
, true);
1903 handleCreateFocus: function(ev
) {
1904 var input
= ev
.currentTarget
,
1905 li
= findParent(input
, 'li'),
1906 cbox
= li
.querySelector('input[type="checkbox"]'),
1907 sb
= findParent(input
, '.cbi-dropdown');
1910 cbox
.checked
= true;
1912 sb
.setAttribute('locked-in', '');
1913 this.setFocus(sb
, li
, true);
1917 handleCreateBlur: function(ev
) {
1918 var input
= ev
.currentTarget
,
1919 cbox
= findParent(input
, 'li').querySelector('input[type="checkbox"]'),
1920 sb
= findParent(input
, '.cbi-dropdown');
1923 cbox
.checked
= false;
1925 sb
.removeAttribute('locked-in');
1929 handleCreateClick: function(ev
) {
1930 ev
.currentTarget
.querySelector(this.options
.create_query
).focus();
1934 setValue: function(values
) {
1935 if (this.options
.multiple
) {
1936 if (!Array
.isArray(values
))
1937 values
= (values
!= null && values
!= '') ? [ values
] : [];
1941 for (var i
= 0; i
< values
.length
; i
++)
1942 v
[values
[i
]] = true;
1944 this.setValues(this.node
, v
);
1949 if (values
!= null) {
1950 if (Array
.isArray(values
))
1951 v
[values
[0]] = true;
1956 this.setValues(this.node
, v
);
1961 getValue: function() {
1962 var div
= this.node
.lastElementChild
,
1963 h
= div
.querySelectorAll('input[type="hidden"]'),
1966 for (var i
= 0; i
< h
.length
; i
++)
1969 return this.options
.multiple
? v
: v
[0];
1974 * Instantiate a rich dropdown choice widget allowing custom values.
1976 * @constructor Combobox
1978 * @augments LuCI.ui.Dropdown
1982 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1983 * to enter custom values. Historically, comboboxes used to be a dedicated
1984 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1985 * with a set of enforced default properties for easier instantiation.
1987 * UI widget instances are usually not supposed to be created by view code
1988 * directly, instead they're implicitly created by `LuCI.form` when
1989 * instantiating CBI forms.
1991 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1992 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1993 * external JavaScript, use `L.require("ui").then(...)` and access the
1994 * `Combobox` property of the class instance value.
1996 * @param {string|string[]} [value=null]
1997 * The initial input value(s).
1999 * @param {Object<string, *>} choices
2000 * Object containing the selectable choices of the widget. The object keys
2001 * serve as values for the different choices while the values are used as
2004 * @param {LuCI.ui.Combobox.InitOptions} [options]
2005 * Object describing the widget specific options to initialize the dropdown.
2007 var UICombobox
= UIDropdown
.extend(/** @lends LuCI.ui.Combobox.prototype */ {
2009 * Comboboxes support the same properties as
2010 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2011 * specific values for the following properties:
2013 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2014 * @memberof LuCI.ui.Combobox
2016 * @property {boolean} multiple=false
2017 * Since Comboboxes never allow selecting multiple values, this property
2018 * is forcibly set to `false`.
2020 * @property {boolean} create=true
2021 * Since Comboboxes always allow custom choice values, this property is
2022 * forcibly set to `true`.
2024 * @property {boolean} optional=true
2025 * Since Comboboxes are always optional, this property is forcibly set to
2028 __init__: function(value
, choices
, options
) {
2029 this.super('__init__', [ value
, choices
, Object
.assign({
2030 select_placeholder
: _('-- Please choose --'),
2031 custom_placeholder
: _('-- custom --'),
2043 * Instantiate a combo button widget offering multiple action choices.
2045 * @constructor ComboButton
2047 * @augments LuCI.ui.Dropdown
2051 * The `ComboButton` class implements a button element which can be expanded
2052 * into a dropdown to chose from a set of different action choices.
2054 * UI widget instances are usually not supposed to be created by view code
2055 * directly, instead they're implicitly created by `LuCI.form` when
2056 * instantiating CBI forms.
2058 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2059 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
2060 * external JavaScript, use `L.require("ui").then(...)` and access the
2061 * `ComboButton` property of the class instance value.
2063 * @param {string|string[]} [value=null]
2064 * The initial input value(s).
2066 * @param {Object<string, *>} choices
2067 * Object containing the selectable choices of the widget. The object keys
2068 * serve as values for the different choices while the values are used as
2071 * @param {LuCI.ui.ComboButton.InitOptions} [options]
2072 * Object describing the widget specific options to initialize the button.
2074 var UIComboButton
= UIDropdown
.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
2076 * ComboButtons support the same properties as
2077 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2078 * specific values for some properties and add additional button specific
2081 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2082 * @memberof LuCI.ui.ComboButton
2084 * @property {boolean} multiple=false
2085 * Since ComboButtons never allow selecting multiple actions, this property
2086 * is forcibly set to `false`.
2088 * @property {boolean} create=false
2089 * Since ComboButtons never allow creating custom choices, this property
2090 * is forcibly set to `false`.
2092 * @property {boolean} optional=false
2093 * Since ComboButtons must always select one action, this property is
2094 * forcibly set to `false`.
2096 * @property {Object<string, string>} [classes]
2097 * Specifies a mapping of choice values to CSS class names. If an action
2098 * choice is selected by the user and if a corresponding entry exists in
2099 * the `classes` object, the class names corresponding to the selected
2100 * value are set on the button element.
2102 * This is useful to apply different button styles, such as colors, to the
2103 * combined button depending on the selected action.
2105 * @property {function} [click]
2106 * Specifies a handler function to invoke when the user clicks the button.
2107 * This function will be called with the button DOM node as `this` context
2108 * and receive the DOM click event as first as well as the selected action
2109 * choice value as second argument.
2111 __init__: function(value
, choices
, options
) {
2112 this.super('__init__', [ value
, choices
, Object
.assign({
2122 render: function(/* ... */) {
2123 var node
= UIDropdown
.prototype.render
.apply(this, arguments
),
2124 val
= this.getValue();
2126 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2127 node
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2133 handleClick: function(ev
) {
2134 var sb
= ev
.currentTarget
,
2137 if (sb
.hasAttribute('open') || dom
.matches(t
, '.cbi-dropdown > span.open'))
2138 return UIDropdown
.prototype.handleClick
.apply(this, arguments
);
2140 if (this.options
.click
)
2141 return this.options
.click
.call(sb
, ev
, this.getValue());
2145 toggleItem: function(sb
/*, ... */) {
2146 var rv
= UIDropdown
.prototype.toggleItem
.apply(this, arguments
),
2147 val
= this.getValue();
2149 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2150 sb
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2152 sb
.setAttribute('class', 'cbi-dropdown');
2159 * Instantiate a dynamic list widget.
2161 * @constructor DynamicList
2163 * @augments LuCI.ui.AbstractElement
2167 * The `DynamicList` class implements a widget which allows the user to specify
2168 * an arbitrary amount of input values, either from free formed text input or
2169 * from a set of predefined choices.
2171 * UI widget instances are usually not supposed to be created by view code
2172 * directly, instead they're implicitly created by `LuCI.form` when
2173 * instantiating CBI forms.
2175 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2176 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2177 * external JavaScript, use `L.require("ui").then(...)` and access the
2178 * `DynamicList` property of the class instance value.
2180 * @param {string|string[]} [value=null]
2181 * The initial input value(s).
2183 * @param {Object<string, *>} [choices]
2184 * Object containing the selectable choices of the widget. The object keys
2185 * serve as values for the different choices while the values are used as
2186 * choice labels. If omitted, no default choices are presented to the user,
2187 * instead a plain text input field is rendered allowing the user to add
2188 * arbitrary values to the dynamic list.
2190 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2191 * Object describing the widget specific options to initialize the dynamic list.
2193 var UIDynamicList
= UIElement
.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2195 * In case choices are passed to the dynamic list constructor, the widget
2196 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2197 * but enforces specific values for some dropdown properties.
2199 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2200 * @memberof LuCI.ui.DynamicList
2202 * @property {boolean} multiple=false
2203 * Since dynamic lists never allow selecting multiple choices when adding
2204 * another list item, this property is forcibly set to `false`.
2206 * @property {boolean} optional=true
2207 * Since dynamic lists use an embedded dropdown to present a list of
2208 * predefined choice values, the dropdown must be made optional to allow
2209 * it to remain unselected.
2211 __init__: function(values
, choices
, options
) {
2212 if (!Array
.isArray(values
))
2213 values
= (values
!= null && values
!= '') ? [ values
] : [];
2215 if (typeof(choices
) != 'object')
2218 this.values
= values
;
2219 this.choices
= choices
;
2220 this.options
= Object
.assign({}, options
, {
2227 render: function() {
2229 'id': this.options
.id
,
2230 'class': 'cbi-dynlist',
2231 'disabled': this.options
.disabled
? '' : null
2232 }, E('div', { 'class': 'add-item control-group' }));
2235 if (this.options
.placeholder
!= null)
2236 this.options
.select_placeholder
= this.options
.placeholder
;
2238 var cbox
= new UICombobox(null, this.choices
, this.options
);
2240 dl
.lastElementChild
.appendChild(cbox
.render());
2243 var inputEl
= E('input', {
2244 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
2246 'class': 'cbi-input-text',
2247 'placeholder': this.options
.placeholder
,
2248 'disabled': this.options
.disabled
? '' : null
2251 dl
.lastElementChild
.appendChild(inputEl
);
2252 dl
.lastElementChild
.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2254 if (this.options
.datatype
|| this.options
.validate
)
2255 UI
.prototype.addValidator(inputEl
, this.options
.datatype
|| 'string',
2256 true, this.options
.validate
, 'blur', 'keyup');
2259 for (var i
= 0; i
< this.values
.length
; i
++) {
2260 var label
= this.choices
? this.choices
[this.values
[i
]] : null;
2262 if (dom
.elem(label
))
2263 label
= label
.cloneNode(true);
2265 this.addItem(dl
, this.values
[i
], label
);
2268 return this.bind(dl
);
2272 bind: function(dl
) {
2273 dl
.addEventListener('click', L
.bind(this.handleClick
, this));
2274 dl
.addEventListener('keydown', L
.bind(this.handleKeydown
, this));
2275 dl
.addEventListener('cbi-dropdown-change', L
.bind(this.handleDropdownChange
, this));
2279 this.setUpdateEvents(dl
, 'cbi-dynlist-change');
2280 this.setChangeEvents(dl
, 'cbi-dynlist-change');
2282 dom
.bindClassInstance(dl
, this);
2288 addItem: function(dl
, value
, text
, flash
) {
2290 new_item
= E('div', { 'class': flash
? 'item flash' : 'item', 'tabindex': 0 }, [
2291 E('span', {}, [ text
|| value
]),
2294 'name': this.options
.name
,
2295 'value': value
})]);
2297 dl
.querySelectorAll('.item').forEach(function(item
) {
2301 var hidden
= item
.querySelector('input[type="hidden"]');
2303 if (hidden
&& hidden
.parentNode
!== item
)
2306 if (hidden
&& hidden
.value
=== value
)
2311 var ai
= dl
.querySelector('.add-item');
2312 ai
.parentNode
.insertBefore(new_item
, ai
);
2315 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2327 removeItem: function(dl
, item
) {
2328 var value
= item
.querySelector('input[type="hidden"]').value
;
2329 var sb
= dl
.querySelector('.cbi-dropdown');
2331 sb
.querySelectorAll('ul > li').forEach(function(li
) {
2332 if (li
.getAttribute('data-value') === value
) {
2333 if (li
.hasAttribute('dynlistcustom'))
2334 li
.parentNode
.removeChild(li
);
2336 li
.removeAttribute('unselectable');
2340 item
.parentNode
.removeChild(item
);
2342 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2354 handleClick: function(ev
) {
2355 var dl
= ev
.currentTarget
,
2356 item
= findParent(ev
.target
, '.item');
2358 if (this.options
.disabled
)
2362 this.removeItem(dl
, item
);
2364 else if (matchesElem(ev
.target
, '.cbi-button-add')) {
2365 var input
= ev
.target
.previousElementSibling
;
2366 if (input
.value
.length
&& !input
.classList
.contains('cbi-input-invalid')) {
2367 this.addItem(dl
, input
.value
, null, true);
2374 handleDropdownChange: function(ev
) {
2375 var dl
= ev
.currentTarget
,
2376 sbIn
= ev
.detail
.instance
,
2377 sbEl
= ev
.detail
.element
,
2378 sbVal
= ev
.detail
.value
;
2383 sbIn
.setValues(sbEl
, null);
2384 sbVal
.element
.setAttribute('unselectable', '');
2386 if (sbVal
.element
.hasAttribute('created')) {
2387 sbVal
.element
.removeAttribute('created');
2388 sbVal
.element
.setAttribute('dynlistcustom', '');
2391 var label
= sbVal
.text
;
2393 if (sbVal
.element
) {
2396 for (var i
= 0; i
< sbVal
.element
.childNodes
.length
; i
++)
2397 label
.appendChild(sbVal
.element
.childNodes
[i
].cloneNode(true));
2400 this.addItem(dl
, sbVal
.value
, label
, true);
2404 handleKeydown: function(ev
) {
2405 var dl
= ev
.currentTarget
,
2406 item
= findParent(ev
.target
, '.item');
2409 switch (ev
.keyCode
) {
2410 case 8: /* backspace */
2411 if (item
.previousElementSibling
)
2412 item
.previousElementSibling
.focus();
2414 this.removeItem(dl
, item
);
2417 case 46: /* delete */
2418 if (item
.nextElementSibling
) {
2419 if (item
.nextElementSibling
.classList
.contains('item'))
2420 item
.nextElementSibling
.focus();
2422 item
.nextElementSibling
.firstElementChild
.focus();
2425 this.removeItem(dl
, item
);
2429 else if (matchesElem(ev
.target
, '.cbi-input-text')) {
2430 switch (ev
.keyCode
) {
2431 case 13: /* enter */
2432 if (ev
.target
.value
.length
&& !ev
.target
.classList
.contains('cbi-input-invalid')) {
2433 this.addItem(dl
, ev
.target
.value
, null, true);
2434 ev
.target
.value
= '';
2439 ev
.preventDefault();
2446 getValue: function() {
2447 var items
= this.node
.querySelectorAll('.item > input[type="hidden"]'),
2448 input
= this.node
.querySelector('.add-item > input[type="text"]'),
2451 for (var i
= 0; i
< items
.length
; i
++)
2452 v
.push(items
[i
].value
);
2454 if (input
&& input
.value
!= null && input
.value
.match(/\S/) &&
2455 input
.classList
.contains('cbi-input-invalid') == false &&
2456 v
.filter(function(s
) { return s
== input
.value
}).length
== 0)
2457 v
.push(input
.value
);
2463 setValue: function(values
) {
2464 if (!Array
.isArray(values
))
2465 values
= (values
!= null && values
!= '') ? [ values
] : [];
2467 var items
= this.node
.querySelectorAll('.item');
2469 for (var i
= 0; i
< items
.length
; i
++)
2470 if (items
[i
].parentNode
=== this.node
)
2471 this.removeItem(this.node
, items
[i
]);
2473 for (var i
= 0; i
< values
.length
; i
++)
2474 this.addItem(this.node
, values
[i
],
2475 this.choices
? this.choices
[values
[i
]] : null);
2479 * Add new suggested choices to the dynamic list.
2481 * This function adds further choices to an existing dynamic list,
2482 * ignoring choice values which are already present.
2485 * @memberof LuCI.ui.DynamicList
2486 * @param {string[]} values
2487 * The choice values to add to the dynamic lists suggestion dropdown.
2489 * @param {Object<string, *>} labels
2490 * The choice label values to use when adding suggested choices. If no
2491 * label is found for a particular choice value, the value itself is used
2492 * as label text. Choice labels may be any valid value accepted by
2493 * {@link LuCI.dom#content}.
2495 addChoices: function(values
, labels
) {
2496 var dl
= this.node
.lastElementChild
.firstElementChild
;
2497 dom
.callClassMethod(dl
, 'addChoices', values
, labels
);
2501 * Remove all existing choices from the dynamic list.
2503 * This function removes all preexisting suggested choices from the widget.
2506 * @memberof LuCI.ui.DynamicList
2508 clearChoices: function() {
2509 var dl
= this.node
.lastElementChild
.firstElementChild
;
2510 dom
.callClassMethod(dl
, 'clearChoices');
2515 * Instantiate a hidden input field widget.
2517 * @constructor Hiddenfield
2519 * @augments LuCI.ui.AbstractElement
2523 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2524 * which allows to store form data without exposing it to the user.
2526 * UI widget instances are usually not supposed to be created by view code
2527 * directly, instead they're implicitly created by `LuCI.form` when
2528 * instantiating CBI forms.
2530 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2531 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2532 * external JavaScript, use `L.require("ui").then(...)` and access the
2533 * `Hiddenfield` property of the class instance value.
2535 * @param {string|string[]} [value=null]
2536 * The initial input value.
2538 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2539 * Object describing the widget specific options to initialize the hidden input.
2541 var UIHiddenfield
= UIElement
.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2542 __init__: function(value
, options
) {
2544 this.options
= Object
.assign({
2550 render: function() {
2551 var hiddenEl
= E('input', {
2552 'id': this.options
.id
,
2557 return this.bind(hiddenEl
);
2561 bind: function(hiddenEl
) {
2562 this.node
= hiddenEl
;
2564 dom
.bindClassInstance(hiddenEl
, this);
2570 getValue: function() {
2571 return this.node
.value
;
2575 setValue: function(value
) {
2576 this.node
.value
= value
;
2581 * Instantiate a file upload widget.
2583 * @constructor FileUpload
2585 * @augments LuCI.ui.AbstractElement
2589 * The `FileUpload` class implements a widget which allows the user to upload,
2590 * browse, select and delete files beneath a predefined remote directory.
2592 * UI widget instances are usually not supposed to be created by view code
2593 * directly, instead they're implicitly created by `LuCI.form` when
2594 * instantiating CBI forms.
2596 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2597 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2598 * external JavaScript, use `L.require("ui").then(...)` and access the
2599 * `FileUpload` property of the class instance value.
2601 * @param {string|string[]} [value=null]
2602 * The initial input value.
2604 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2605 * Object describing the widget specific options to initialize the file
2608 var UIFileUpload
= UIElement
.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2610 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2611 * the following properties are recognized:
2613 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2614 * @memberof LuCI.ui.FileUpload
2616 * @property {boolean} [browser=false]
2617 * Use a file browser mode.
2619 * @property {boolean} [show_hidden=false]
2620 * Specifies whether hidden files should be displayed when browsing remote
2621 * files. Note that this is not a security feature, hidden files are always
2622 * present in the remote file listings received, this option merely controls
2623 * whether they're displayed or not.
2625 * @property {boolean} [enable_upload=true]
2626 * Specifies whether the widget allows the user to upload files. If set to
2627 * `false`, only existing files may be selected. Note that this is not a
2628 * security feature. Whether file upload requests are accepted remotely
2629 * depends on the ACL setup for the current session. This option merely
2630 * controls whether the upload controls are rendered or not.
2632 * @property {boolean} [enable_remove=true]
2633 * Specifies whether the widget allows the user to delete remove files.
2634 * If set to `false`, existing files may not be removed. Note that this is
2635 * not a security feature. Whether file delete requests are accepted
2636 * remotely depends on the ACL setup for the current session. This option
2637 * merely controls whether the file remove controls are rendered or not.
2639 * @property {string} [root_directory=/etc/luci-uploads]
2640 * Specifies the remote directory the upload and file browsing actions take
2641 * place in. Browsing to directories outside the root directory is
2642 * prevented by the widget. Note that this is not a security feature.
2643 * Whether remote directories are browsable or not solely depends on the
2644 * ACL setup for the current session.
2646 __init__: function(value
, options
) {
2648 this.options
= Object
.assign({
2651 enable_upload
: true,
2652 enable_remove
: true,
2653 root_directory
: '/etc/luci-uploads'
2658 bind: function(browserEl
) {
2659 this.node
= browserEl
;
2661 this.setUpdateEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2662 this.setChangeEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2664 dom
.bindClassInstance(browserEl
, this);
2670 render: function() {
2671 var renderFileBrowser
= L
.resolveDefault(this.value
!= null ? fs
.stat(this.value
) : null).then(L
.bind(function(stat
) {
2674 if (L
.isObject(stat
) && stat
.type
!= 'directory')
2677 if (this.stat
!= null)
2678 label
= [ this.iconForType(this.stat
.type
), ' %s (%1000mB)'.format(this.truncatePath(this.stat
.path
), this.stat
.size
) ];
2679 else if (this.value
!= null)
2680 label
= [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value
), _('File not accessible')) ];
2682 label
= [ _('Select file…') ];
2683 let btnOpenFileBrowser
= E('button', {
2684 'class': 'btn open-file-browser',
2685 'click': UI
.prototype.createHandlerFn(this, 'handleFileBrowser'),
2686 'disabled': this.options
.disabled
? '' : null
2688 var fileBrowserEl
= E('div', { 'id': this.options
.id
}, [
2691 'class': 'cbi-filebrowser'
2695 'name': this.options
.name
,
2699 return this.bind(fileBrowserEl
);
2701 // in a browser mode open dir listing after render by clicking on a Select button
2702 if (this.options
.browser
) {
2703 return renderFileBrowser
.then(function (fileBrowserEl
) {
2704 var btnOpenFileBrowser
= fileBrowserEl
.getElementsByClassName('open-file-browser').item(0);
2705 btnOpenFileBrowser
.click();
2706 return fileBrowserEl
;
2709 return renderFileBrowser
2713 truncatePath: function(path
) {
2714 if (path
.length
> 50)
2715 path
= path
.substring(0, 25) + '…' + path
.substring(path
.length
- 25);
2721 iconForType: function(type
) {
2725 'src': L
.resource('cbi/link.svg'),
2727 'title': _('Symbolic link'),
2733 'src': L
.resource('cbi/folder.svg'),
2735 'title': _('Directory'),
2741 'src': L
.resource('cbi/file.svg'),
2750 canonicalizePath: function(path
) {
2751 return path
.replace(/\/{2,}/, '/')
2752 .replace(/\/\.(\/|$)/g, '/')
2753 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2754 .replace(/\/$/, '');
2758 splitPath: function(path
) {
2759 var croot
= this.canonicalizePath(this.options
.root_directory
|| '/'),
2760 cpath
= this.canonicalizePath(path
|| '/');
2762 if (cpath
.length
<= croot
.length
)
2765 if (cpath
.charAt(croot
.length
) != '/')
2768 var parts
= cpath
.substring(croot
.length
+ 1).split(/\//);
2770 parts
.unshift(croot
);
2776 handleUpload: function(path
, list
, ev
) {
2777 var form
= ev
.target
.parentNode
,
2778 fileinput
= form
.querySelector('input[type="file"]'),
2779 nameinput
= form
.querySelector('input[type="text"]'),
2780 filename
= (nameinput
.value
!= null ? nameinput
.value
: '').trim();
2782 ev
.preventDefault();
2784 if (filename
== '' || filename
.match(/\//) || fileinput
.files
[0] == null)
2787 var existing
= list
.filter(function(e
) { return e
.name
== filename
})[0];
2789 if (existing
!= null && existing
.type
== 'directory')
2790 return alert(_('A directory with the same name already exists.'));
2791 else if (existing
!= null && !confirm(_('Overwrite existing file "%s" ?').format(filename
)))
2794 var data
= new FormData();
2796 data
.append('sessionid', L
.env
.sessionid
);
2797 data
.append('filename', path
+ '/' + filename
);
2798 data
.append('filedata', fileinput
.files
[0]);
2800 return request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
2801 progress
: L
.bind(function(btn
, ev
) {
2802 btn
.firstChild
.data
= '%.2f%%'.format((ev
.loaded
/ ev
.total
) * 100);
2804 }).then(L
.bind(function(path
, ev
, res
) {
2805 var reply
= res
.json();
2807 if (L
.isObject(reply
) && reply
.failure
)
2808 alert(_('Upload request failed: %s').format(reply
.message
));
2810 return this.handleSelect(path
, null, ev
);
2811 }, this, path
, ev
));
2815 handleDelete: function(path
, fileStat
, ev
) {
2816 var parent
= path
.replace(/\/[^\/]+$/, '') || '/',
2817 name
= path
.replace(/^.+\//, ''),
2820 ev
.preventDefault();
2822 if (fileStat
.type
== 'directory')
2823 msg
= _('Do you really want to recursively delete the directory "%s" ?').format(name
);
2825 msg
= _('Do you really want to delete "%s" ?').format(name
);
2828 var button
= this.node
.firstElementChild
,
2829 hidden
= this.node
.lastElementChild
;
2831 if (path
== hidden
.value
) {
2832 dom
.content(button
, _('Select file…'));
2836 return fs
.remove(path
).then(L
.bind(function(parent
, ev
) {
2837 return this.handleSelect(parent
, null, ev
);
2838 }, this, parent
, ev
)).catch(function(err
) {
2839 alert(_('Delete request failed: %s').format(err
.message
));
2845 renderUpload: function(path
, list
) {
2846 if (!this.options
.enable_upload
)
2852 'class': 'btn cbi-button-positive',
2853 'click': function(ev
) {
2854 var uploadForm
= ev
.target
.nextElementSibling
,
2855 fileInput
= uploadForm
.querySelector('input[type="file"]');
2857 ev
.target
.style
.display
= 'none';
2858 uploadForm
.style
.display
= '';
2861 }, _('Upload file…')),
2862 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2865 'style': 'display:none',
2866 'change': function(ev
) {
2867 var nameinput
= ev
.target
.parentNode
.querySelector('input[type="text"]'),
2868 uploadbtn
= ev
.target
.parentNode
.querySelector('button.cbi-button-save');
2870 nameinput
.value
= ev
.target
.value
.replace(/^.+[\/\\]/, '');
2871 uploadbtn
.disabled
= false;
2876 'click': function(ev
) {
2877 ev
.preventDefault();
2878 ev
.target
.previousElementSibling
.click();
2880 }, [ _('Browse…') ]),
2881 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2883 'class': 'btn cbi-button-save',
2884 'click': UI
.prototype.createHandlerFn(this, 'handleUpload', path
, list
),
2886 }, [ _('Upload file') ])
2892 renderListing: function(container
, path
, list
) {
2893 var breadcrumb
= E('p'),
2896 list
.sort(function(a
, b
) {
2897 return L
.naturalCompare(a
.type
== 'directory', b
.type
== 'directory') ||
2898 L
.naturalCompare(a
.name
, b
.name
);
2901 for (var i
= 0; i
< list
.length
; i
++) {
2902 if (!this.options
.show_hidden
&& list
[i
].name
.charAt(0) == '.')
2905 var entrypath
= this.canonicalizePath(path
+ '/' + list
[i
].name
),
2906 selected
= (entrypath
== this.node
.lastElementChild
.value
),
2907 mtime
= new Date(list
[i
].mtime
* 1000);
2909 rows
.appendChild(E('li', [
2910 E('div', { 'class': 'name' }, [
2911 this.iconForType(list
[i
].type
),
2915 'style': selected
? 'font-weight:bold' : null,
2916 'click': UI
.prototype.createHandlerFn(this, 'handleSelect',
2917 entrypath
, list
[i
].type
!= 'directory' ? list
[i
] : null)
2918 }, '%h'.format(list
[i
].name
))
2920 E('div', { 'class': 'mtime hide-xs' }, [
2921 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2922 mtime
.getFullYear(),
2923 mtime
.getMonth() + 1,
2930 selected
? E('button', {
2932 'click': UI
.prototype.createHandlerFn(this, 'handleReset')
2933 }, [ _('Deselect') ]) : '',
2934 this.options
.enable_remove
? E('button', {
2935 'class': 'btn cbi-button-negative',
2936 'click': UI
.prototype.createHandlerFn(this, 'handleDelete', entrypath
, list
[i
])
2937 }, [ _('Delete') ]) : ''
2942 if (!rows
.firstElementChild
)
2943 rows
.appendChild(E('em', _('No entries in this directory')));
2945 var dirs
= this.splitPath(path
),
2948 for (var i
= 0; i
< dirs
.length
; i
++) {
2949 cur
= cur
? cur
+ '/' + dirs
[i
] : dirs
[i
];
2950 dom
.append(breadcrumb
, [
2954 'click': UI
.prototype.createHandlerFn(this, 'handleSelect', cur
|| '/', null)
2955 }, dirs
[i
] != '' ? '%h'.format(dirs
[i
]) : E('em', '(root)')),
2959 dom
.content(container
, [
2962 E('div', { 'class': 'right' }, [
2963 this.renderUpload(path
, list
),
2964 !this.options
.browser
? E('a', {
2967 'click': UI
.prototype.createHandlerFn(this, 'handleCancel')
2968 }, _('Cancel')) : ''
2974 handleCancel: function(ev
) {
2975 var button
= this.node
.firstElementChild
,
2976 browser
= button
.nextElementSibling
;
2978 browser
.classList
.remove('open');
2979 button
.style
.display
= '';
2981 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2983 ev
.preventDefault();
2987 handleReset: function(ev
) {
2988 var button
= this.node
.firstElementChild
,
2989 hidden
= this.node
.lastElementChild
;
2992 dom
.content(button
, _('Select file…'));
2994 this.handleCancel(ev
);
2998 handleSelect: function(path
, fileStat
, ev
) {
2999 var browser
= dom
.parent(ev
.target
, '.cbi-filebrowser'),
3000 ul
= browser
.querySelector('ul');
3002 if (fileStat
== null) {
3003 dom
.content(ul
, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
3004 L
.resolveDefault(fs
.list(path
), []).then(L
.bind(this.renderListing
, this, browser
, path
));
3006 else if (!this.options
.browser
) {
3007 var button
= this.node
.firstElementChild
,
3008 hidden
= this.node
.lastElementChild
;
3010 path
= this.canonicalizePath(path
);
3012 dom
.content(button
, [
3013 this.iconForType(fileStat
.type
),
3014 ' %s (%1000mB)'.format(this.truncatePath(path
), fileStat
.size
)
3017 browser
.classList
.remove('open');
3018 button
.style
.display
= '';
3019 hidden
.value
= path
;
3021 this.stat
= Object
.assign({ path
: path
}, fileStat
);
3022 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail
: this.stat
}));
3027 handleFileBrowser: function(ev
) {
3028 var button
= ev
.target
,
3029 browser
= button
.nextElementSibling
,
3030 path
= this.stat
? this.stat
.path
.replace(/\/[^\/]+$/, '') : (this.options
.initial_directory
|| this.options
.root_directory
);
3032 if (path
.indexOf(this.options
.root_directory
) != 0)
3033 path
= this.options
.root_directory
;
3035 ev
.preventDefault();
3037 return L
.resolveDefault(fs
.list(path
), []).then(L
.bind(function(button
, browser
, path
, list
) {
3038 document
.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl
) {
3039 dom
.findClassInstance(browserEl
).handleCancel(ev
);
3042 button
.style
.display
= 'none';
3043 browser
.classList
.add('open');
3045 return this.renderListing(browser
, path
, list
);
3046 }, this, button
, browser
, path
));
3050 getValue: function() {
3051 return this.node
.lastElementChild
.value
;
3055 setValue: function(value
) {
3056 this.node
.lastElementChild
.value
= value
;
3061 function scrubMenu(node
) {
3062 var hasSatisfiedChild
= false;
3064 if (L
.isObject(node
.children
)) {
3065 for (var k
in node
.children
) {
3066 var child
= scrubMenu(node
.children
[k
]);
3068 if (child
.title
&& !child
.firstchild_ineligible
)
3069 hasSatisfiedChild
= hasSatisfiedChild
|| child
.satisfied
;
3073 if (L
.isObject(node
.action
) &&
3074 node
.action
.type
== 'firstchild' &&
3075 hasSatisfiedChild
== false)
3076 node
.satisfied
= false;
3091 var UIMenu
= baseclass
.singleton(/** @lends LuCI.ui.menu.prototype */ {
3093 * @typedef {Object} MenuNode
3094 * @memberof LuCI.ui.menu
3096 * @property {string} name - The internal name of the node, as used in the URL
3097 * @property {number} order - The sort index of the menu node
3098 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
3099 * @property {satisfied} boolean - Boolean indicating whether the menu entries dependencies are satisfied
3100 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
3101 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
3105 * Load and cache current menu tree.
3107 * @returns {Promise<LuCI.ui.menu.MenuNode>}
3108 * Returns a promise resolving to the root element of the menu tree.
3111 if (this.menu
== null)
3112 this.menu
= session
.getLocalData('menu');
3114 if (!L
.isObject(this.menu
)) {
3115 this.menu
= request
.get(L
.url('admin/menu')).then(L
.bind(function(menu
) {
3116 this.menu
= scrubMenu(menu
.json());
3117 session
.setLocalData('menu', this.menu
);
3123 return Promise
.resolve(this.menu
);
3127 * Flush the internal menu cache to force loading a new structure on the
3130 flushCache: function() {
3131 session
.setLocalData('menu', null);
3135 * @param {LuCI.ui.menu.MenuNode} [node]
3136 * The menu node to retrieve the children for. Defaults to the menu's
3137 * internal root node if omitted.
3139 * @returns {LuCI.ui.menu.MenuNode[]}
3140 * Returns an array of child menu nodes.
3142 getChildren: function(node
) {
3148 for (var k
in node
.children
) {
3149 if (!node
.children
.hasOwnProperty(k
))
3152 if (!node
.children
[k
].satisfied
)
3155 if (!node
.children
[k
].hasOwnProperty('title'))
3158 var subnode
= Object
.assign(node
.children
[k
], { name
: k
});
3160 if (L
.isObject(subnode
.action
) && subnode
.action
.path
!= null &&
3161 (subnode
.action
.type
== 'alias' || subnode
.action
.type
== 'rewrite')) {
3162 var root
= this.menu
,
3163 path
= subnode
.action
.path
.split('/');
3165 for (var i
= 0; root
!= null && i
< path
.length
; i
++)
3166 root
= L
.isObject(root
.children
) ? root
.children
[path
[i
]] : null;
3169 subnode
= Object
.assign({}, subnode
, {
3170 children
: root
.children
,
3175 children
.push(subnode
);
3178 return children
.sort(function(a
, b
) {
3179 var wA
= a
.order
|| 1000,
3180 wB
= b
.order
|| 1000;
3185 return L
.naturalCompare(a
.name
, b
.name
);
3190 var UITable
= baseclass
.extend(/** @lends LuCI.ui.table.prototype */ {
3191 __init__: function(captions
, options
, placeholder
) {
3192 if (!Array
.isArray(captions
)) {
3193 this.initFromMarkup(captions
);
3198 var id
= options
.id
|| 'table%08x'.format(Math
.random() * 0xffffffff);
3200 var table
= E('table', { 'id': id
, 'class': 'table' }, [
3201 E('tr', { 'class': 'tr table-titles', 'click': UI
.prototype.createHandlerFn(this, 'handleSort') })
3206 this.options
= options
;
3208 var sorting
= this.getActiveSortState();
3210 for (var i
= 0; i
< captions
.length
; i
++) {
3211 if (captions
[i
] == null)
3214 var th
= E('th', { 'class': 'th' }, [ captions
[i
] ]);
3216 if (typeof(options
.captionClasses
) == 'object')
3217 DOMTokenList
.prototype.add
.apply(th
.classList
, L
.toArray(options
.captionClasses
[i
]));
3219 if (options
.sortable
!== false && (typeof(options
.sortable
) != 'object' || options
.sortable
[i
] !== false)) {
3220 th
.setAttribute('data-sortable-row', true);
3222 if (sorting
&& sorting
[0] == i
)
3223 th
.setAttribute('data-sort-direction', sorting
[1] ? 'desc' : 'asc');
3226 table
.firstElementChild
.appendChild(th
);
3230 var trow
= table
.appendChild(E('tr', { 'class': 'tr placeholder' })),
3231 td
= trow
.appendChild(E('td', { 'class': 'td' }, placeholder
));
3233 if (typeof(captionClasses
) == 'object')
3234 DOMTokenList
.prototype.add
.apply(td
.classList
, L
.toArray(captionClasses
[0]));
3237 DOMTokenList
.prototype.add
.apply(table
.classList
, L
.toArray(options
.classes
));
3240 update: function(data
, placeholder
) {
3241 var placeholder
= placeholder
|| this.options
.placeholder
|| _('No data', 'empty table placeholder'),
3242 sorting
= this.getActiveSortState();
3244 if (!Array
.isArray(data
))
3248 this.placeholder
= placeholder
;
3251 rows
= this.node
.querySelectorAll('tr, .tr'),
3253 headings
= [].slice
.call(this.node
.firstElementChild
.querySelectorAll('th, .th')),
3254 captionClasses
= this.options
.captionClasses
,
3255 trTag
= (rows
[0] && rows
[0].nodeName
== 'DIV') ? 'div' : 'tr',
3256 tdTag
= (headings
[0] && headings
[0].nodeName
== 'DIV') ? 'div' : 'td';
3259 var list
= data
.map(L
.bind(function(row
) {
3260 return [ this.deriveSortKey(row
[sorting
[0]], sorting
[0]), row
];
3263 list
.sort(function(a
, b
) {
3265 ? -L
.naturalCompare(a
[0], b
[0])
3266 : L
.naturalCompare(a
[0], b
[0]);
3271 list
.forEach(function(item
) {
3275 headings
.forEach(function(th
, i
) {
3276 if (i
== sorting
[0])
3277 th
.setAttribute('data-sort-direction', sorting
[1] ? 'desc' : 'asc');
3279 th
.removeAttribute('data-sort-direction');
3283 data
.forEach(function(row
) {
3284 trows
[n
] = E(trTag
, { 'class': 'tr' });
3286 for (var i
= 0; i
< headings
.length
; i
++) {
3287 var text
= (headings
[i
].innerText
|| '').trim();
3288 var raw_val
= Array
.isArray(row
[i
]) ? row
[i
][0] : null;
3289 var disp_val
= Array
.isArray(row
[i
]) ? row
[i
][1] : row
[i
];
3290 var td
= trows
[n
].appendChild(E(tdTag
, {
3292 'data-title': (text
!== '') ? text
: null,
3293 'data-value': raw_val
3294 }, (disp_val
!= null) ? ((disp_val
instanceof DocumentFragment
) ? disp_val
.cloneNode(true) : disp_val
) : ''));
3296 if (typeof(captionClasses
) == 'object')
3297 DOMTokenList
.prototype.add
.apply(td
.classList
, L
.toArray(captionClasses
[i
]));
3299 if (!td
.classList
.contains('cbi-section-actions'))
3300 headings
[i
].setAttribute('data-sortable-row', true);
3303 trows
[n
].classList
.add('cbi-rowstyle-%d'.format((n
++ % 2) ? 2 : 1));
3306 for (var i
= 0; i
< n
; i
++) {
3308 this.node
.replaceChild(trows
[i
], rows
[i
+1]);
3310 this.node
.appendChild(trows
[i
]);
3314 this.node
.removeChild(rows
[n
]);
3316 if (placeholder
&& this.node
.firstElementChild
=== this.node
.lastElementChild
) {
3317 var trow
= this.node
.appendChild(E(trTag
, { 'class': 'tr placeholder' })),
3318 td
= trow
.appendChild(E(tdTag
, { 'class': 'td' }, placeholder
));
3320 if (typeof(captionClasses
) == 'object')
3321 DOMTokenList
.prototype.add
.apply(td
.classList
, L
.toArray(captionClasses
[0]));
3327 render: function() {
3332 initFromMarkup: function(node
) {
3333 if (!dom
.elem(node
))
3334 node
= document
.querySelector(node
);
3337 throw 'Invalid table selector';
3340 headrow
= node
.querySelector('tr, .tr');
3345 options
.id
= node
.id
;
3346 options
.classes
= [].slice
.call(node
.classList
).filter(function(c
) { return c
!= 'table' });
3347 options
.sortable
= [];
3348 options
.captionClasses
= [];
3350 headrow
.querySelectorAll('th, .th').forEach(function(th
, i
) {
3351 options
.sortable
[i
] = !th
.classList
.contains('cbi-section-actions');
3352 options
.captionClasses
[i
] = [].slice
.call(th
.classList
).filter(function(c
) { return c
!= 'th' });
3355 headrow
.addEventListener('click', UI
.prototype.createHandlerFn(this, 'handleSort'));
3359 this.options
= options
;
3363 deriveSortKey: function(value
, index
) {
3364 var opts
= this.options
|| {},
3367 if (opts
.sortable
== true || opts
.sortable
== null)
3369 else if (typeof( opts
.sortable
) == 'object')
3370 hint
= opts
.sortable
[index
];
3372 if (dom
.elem(value
)) {
3373 if (value
.hasAttribute('data-value'))
3374 value
= value
.getAttribute('data-value');
3376 value
= (value
.innerText
|| '').trim();
3379 switch (hint
|| 'auto') {
3382 m
= /^([0-9a-fA-F:.]+)(?:\/([0-9a-fA-F:.]+))?$/.exec(value
);
3387 addr
= validation
.parseIPv6(m
[1]);
3388 mask
= m
[2] ? validation
.parseIPv6(m
[2]) : null;
3390 if (addr
&& mask
!= null)
3391 return '%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x'.format(
3392 addr
[0], addr
[1], addr
[2], addr
[3], addr
[4], addr
[5], addr
[6], addr
[7],
3393 mask
[0], mask
[1], mask
[2], mask
[3], mask
[4], mask
[5], mask
[6], mask
[7]
3396 return '%04x%04x%04x%04x%04x%04x%04x%04x%02x'.format(
3397 addr
[0], addr
[1], addr
[2], addr
[3], addr
[4], addr
[5], addr
[6], addr
[7],
3401 addr
= validation
.parseIPv4(m
[1]);
3402 mask
= m
[2] ? validation
.parseIPv4(m
[2]) : null;
3404 if (addr
&& mask
!= null)
3405 return '%03d%03d%03d%03d%03d%03d%03d%03d'.format(
3406 addr
[0], addr
[1], addr
[2], addr
[3],
3407 mask
[0], mask
[1], mask
[2], mask
[3]
3410 return '%03d%03d%03d%03d%02d'.format(
3411 addr
[0], addr
[1], addr
[2], addr
[3],
3416 m
= /^(?:(\d+)d )?(\d+)h (\d+)m (\d+)s$/.exec(value
);
3419 return '%05d%02d%02d%02d'.format(+m
[1], +m
[2], +m
[3], +m
[4]);
3421 m
= /^(\d+)\b(\D*)$/.exec(value
);
3424 return '%010d%s'.format(+m
[1], m
[2]);
3426 return String(value
);
3429 return String(value
).toLowerCase();
3435 return String(value
);
3440 getActiveSortState: function() {
3442 return this.sortState
;
3444 if (!this.options
.id
)
3447 var page
= document
.body
.getAttribute('data-page'),
3448 key
= page
+ '.' + this.options
.id
,
3449 state
= session
.getLocalData('tablesort');
3451 if (L
.isObject(state
) && Array
.isArray(state
[key
]))
3458 setActiveSortState: function(index
, descending
) {
3459 this.sortState
= [ index
, descending
];
3461 if (!this.options
.id
)
3464 var page
= document
.body
.getAttribute('data-page'),
3465 key
= page
+ '.' + this.options
.id
,
3466 state
= session
.getLocalData('tablesort');
3468 if (!L
.isObject(state
))
3471 state
[key
] = this.sortState
;
3473 session
.setLocalData('tablesort', state
);
3477 handleSort: function(ev
) {
3478 if (!ev
.target
.matches('th[data-sortable-row]'))
3481 var index
, direction
;
3483 this.node
.firstElementChild
.querySelectorAll('th, .th').forEach(function(th
, i
) {
3484 if (th
=== ev
.target
) {
3486 direction
= th
.getAttribute('data-sort-direction') == 'asc';
3490 this.setActiveSortState(index
, direction
);
3491 this.update(this.data
, this.placeholder
);
3501 * Provides high level UI helper functionality.
3502 * To import the class in views, use `'require ui'`, to import it in
3503 * external JavaScript, use `L.require("ui").then(...)`.
3505 var UI
= baseclass
.extend(/** @lends LuCI.ui.prototype */ {
3506 __init__: function() {
3507 modalDiv
= document
.body
.appendChild(
3509 id
: 'modal_overlay',
3511 keydown
: this.cancelModal
3520 tooltipDiv
= document
.body
.appendChild(
3521 dom
.create('div', { class: 'cbi-tooltip' }));
3523 /* set up old aliases */
3524 L
.showModal
= this.showModal
;
3525 L
.hideModal
= this.hideModal
;
3526 L
.showTooltip
= this.showTooltip
;
3527 L
.hideTooltip
= this.hideTooltip
;
3528 L
.itemlist
= this.itemlist
;
3530 document
.addEventListener('mouseover', this.showTooltip
.bind(this), true);
3531 document
.addEventListener('mouseout', this.hideTooltip
.bind(this), true);
3532 document
.addEventListener('focus', this.showTooltip
.bind(this), true);
3533 document
.addEventListener('blur', this.hideTooltip
.bind(this), true);
3535 document
.addEventListener('luci-loaded', this.tabs
.init
.bind(this.tabs
));
3536 document
.addEventListener('luci-loaded', this.changes
.init
.bind(this.changes
));
3537 document
.addEventListener('uci-loaded', this.changes
.init
.bind(this.changes
));
3541 * Display a modal overlay dialog with the specified contents.
3543 * The modal overlay dialog covers the current view preventing interaction
3544 * with the underlying view contents. Only one modal dialog instance can
3545 * be opened. Invoking showModal() while a modal dialog is already open will
3546 * replace the open dialog with a new one having the specified contents.
3548 * Additional CSS class names may be passed to influence the appearance of
3549 * the dialog. Valid values for the classes depend on the underlying theme.
3551 * @see LuCI.dom.content
3553 * @param {string} [title]
3554 * The title of the dialog. If `null`, no title element will be rendered.
3556 * @param {*} children
3557 * The contents to add to the modal dialog. This should be a DOM node or
3558 * a document fragment in most cases. The value is passed as-is to the
3559 * `dom.content()` function - refer to its documentation for applicable
3562 * @param {...string} [classes]
3563 * A number of extra CSS class names which are set on the modal dialog
3567 * Returns a DOM Node representing the modal dialog element.
3569 showModal: function(title
, children
/* , ... */) {
3570 var dlg
= modalDiv
.firstElementChild
;
3572 dlg
.setAttribute('class', 'modal');
3574 for (var i
= 2; i
< arguments
.length
; i
++)
3575 dlg
.classList
.add(arguments
[i
]);
3577 dom
.content(dlg
, dom
.create('h4', {}, title
));
3578 dom
.append(dlg
, children
);
3580 document
.body
.classList
.add('modal-overlay-active');
3581 modalDiv
.scrollTop
= 0;
3588 * Close the open modal overlay dialog.
3590 * This function will close an open modal dialog and restore the normal view
3591 * behaviour. It has no effect if no modal dialog is currently open.
3593 * Note that this function is stand-alone, it does not rely on `this` and
3594 * will not invoke other class functions so it is suitable to be used as event
3595 * handler as-is without the need to bind it first.
3597 hideModal: function() {
3598 document
.body
.classList
.remove('modal-overlay-active');
3603 cancelModal: function(ev
) {
3604 if (ev
.key
== 'Escape') {
3605 var btn
= modalDiv
.querySelector('.right > button, .right > .btn');
3613 showTooltip: function(ev
) {
3614 var target
= findParent(ev
.target
, '[data-tooltip]');
3619 if (tooltipTimeout
!== null) {
3620 window
.clearTimeout(tooltipTimeout
);
3621 tooltipTimeout
= null;
3624 var rect
= target
.getBoundingClientRect(),
3625 x
= rect
.left
+ window
.pageXOffset
,
3626 y
= rect
.top
+ rect
.height
+ window
.pageYOffset
,
3629 tooltipDiv
.className
= 'cbi-tooltip';
3630 tooltipDiv
.innerHTML
= '▲ ';
3631 tooltipDiv
.firstChild
.data
+= target
.getAttribute('data-tooltip');
3633 if (target
.hasAttribute('data-tooltip-style'))
3634 tooltipDiv
.classList
.add(target
.getAttribute('data-tooltip-style'));
3636 if ((y
+ tooltipDiv
.offsetHeight
) > (window
.innerHeight
+ window
.pageYOffset
))
3639 var dropdown
= target
.querySelector('ul.dropdown[style]:first-child');
3641 if (dropdown
&& dropdown
.style
.top
)
3645 y
-= (tooltipDiv
.offsetHeight
+ target
.offsetHeight
);
3646 tooltipDiv
.firstChild
.data
= '▼ ' + tooltipDiv
.firstChild
.data
.substr(2);
3649 tooltipDiv
.style
.top
= y
+ 'px';
3650 tooltipDiv
.style
.left
= x
+ 'px';
3651 tooltipDiv
.style
.opacity
= 1;
3653 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-open', {
3655 detail
: { target
: target
}
3660 hideTooltip: function(ev
) {
3661 if (ev
.target
=== tooltipDiv
|| ev
.relatedTarget
=== tooltipDiv
||
3662 tooltipDiv
.contains(ev
.target
) || tooltipDiv
.contains(ev
.relatedTarget
))
3665 if (tooltipTimeout
!== null) {
3666 window
.clearTimeout(tooltipTimeout
);
3667 tooltipTimeout
= null;
3670 tooltipDiv
.style
.opacity
= 0;
3671 tooltipTimeout
= window
.setTimeout(function() { tooltipDiv
.removeAttribute('style'); }, 250);
3673 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-close', { bubbles
: true }));
3677 * Add a notification banner at the top of the current view.
3679 * A notification banner is an alert message usually displayed at the
3680 * top of the current view, spanning the entire available width.
3681 * Notification banners will stay in place until dismissed by the user.
3682 * Multiple banners may be shown at the same time.
3684 * Additional CSS class names may be passed to influence the appearance of
3685 * the banner. Valid values for the classes depend on the underlying theme.
3687 * @see LuCI.dom.content
3689 * @param {string} [title]
3690 * The title of the notification banner. If `null`, no title element
3693 * @param {*} children
3694 * The contents to add to the notification banner. This should be a DOM
3695 * node or a document fragment in most cases. The value is passed as-is
3696 * to the `dom.content()` function - refer to its documentation for
3697 * applicable values.
3699 * @param {...string} [classes]
3700 * A number of extra CSS class names which are set on the notification
3704 * Returns a DOM Node representing the notification banner element.
3706 addNotification: function(title
, children
/*, ... */) {
3707 var mc
= document
.querySelector('#maincontent') || document
.body
;
3708 var msg
= E('div', {
3709 'class': 'alert-message fade-in',
3710 'style': 'display:flex',
3711 'transitionend': function(ev
) {
3712 var node
= ev
.currentTarget
;
3713 if (node
.parentNode
&& node
.classList
.contains('fade-out'))
3714 node
.parentNode
.removeChild(node
);
3717 E('div', { 'style': 'flex:10' }),
3718 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3721 'style': 'margin-left:auto; margin-top:auto',
3722 'click': function(ev
) {
3723 dom
.parent(ev
.target
, '.alert-message').classList
.add('fade-out');
3726 }, [ _('Dismiss') ])
3731 dom
.append(msg
.firstElementChild
, E('h4', {}, title
));
3733 dom
.append(msg
.firstElementChild
, children
);
3735 for (var i
= 2; i
< arguments
.length
; i
++)
3736 msg
.classList
.add(arguments
[i
]);
3738 mc
.insertBefore(msg
, mc
.firstElementChild
);
3744 * Display or update a header area indicator.
3746 * An indicator is a small label displayed in the header area of the screen
3747 * providing few amounts of status information such as item counts or state
3748 * toggle indicators.
3750 * Multiple indicators may be shown at the same time and indicator labels
3751 * may be made clickable to display extended information or to initiate
3754 * Indicators can either use a default `active` or a less accented `inactive`
3755 * style which is useful for indicators representing state toggles.
3757 * @param {string} id
3758 * The ID of the indicator. If an indicator with the given ID already exists,
3759 * it is updated with the given label and style.
3761 * @param {string} label
3762 * The text to display in the indicator label.
3764 * @param {function} [handler]
3765 * A handler function to invoke when the indicator label is clicked/touched
3766 * by the user. If omitted, the indicator is not clickable/touchable.
3768 * Note that this parameter only applies to new indicators, when updating
3769 * existing labels it is ignored.
3771 * @param {"active"|"inactive"} [style=active]
3772 * The indicator style to use. May be either `active` or `inactive`.
3774 * @returns {boolean}
3775 * Returns `true` when the indicator has been updated or `false` when no
3776 * changes were made.
3778 showIndicator: function(id
, label
, handler
, style
) {
3779 if (indicatorDiv
== null) {
3780 indicatorDiv
= document
.body
.querySelector('#indicators');
3782 if (indicatorDiv
== null)
3786 var handlerFn
= (typeof(handler
) == 'function') ? handler
: null,
3787 indicatorElem
= indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
));
3789 if (indicatorElem
== null) {
3790 var beforeElem
= null;
3792 for (beforeElem
= indicatorDiv
.firstElementChild
;
3794 beforeElem
= beforeElem
.nextElementSibling
)
3795 if (beforeElem
.getAttribute('data-indicator') > id
)
3798 indicatorElem
= indicatorDiv
.insertBefore(E('span', {
3799 'data-indicator': id
,
3800 'data-clickable': handlerFn
? true : null,
3802 }, ['']), beforeElem
);
3805 if (label
== indicatorElem
.firstChild
.data
&& style
== indicatorElem
.getAttribute('data-style'))
3808 indicatorElem
.firstChild
.data
= label
;
3809 indicatorElem
.setAttribute('data-style', (style
== 'inactive') ? 'inactive' : 'active');
3814 * Remove a header area indicator.
3816 * This function removes the given indicator label from the header indicator
3817 * area. When the given indicator is not found, this function does nothing.
3819 * @param {string} id
3820 * The ID of the indicator to remove.
3822 * @returns {boolean}
3823 * Returns `true` when the indicator has been removed or `false` when the
3824 * requested indicator was not found.
3826 hideIndicator: function(id
) {
3827 var indicatorElem
= indicatorDiv
? indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
)) : null;
3829 if (indicatorElem
== null)
3832 indicatorDiv
.removeChild(indicatorElem
);
3837 * Formats a series of label/value pairs into list-like markup.
3839 * This function transforms a flat array of alternating label and value
3840 * elements into a list-like markup, using the values in `separators` as
3841 * separators and appends the resulting nodes to the given parent DOM node.
3843 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3844 * `<strong>` element and the value corresponding to the label are
3845 * subsequently wrapped into a `<span class="nowrap">` element.
3847 * The resulting `<span>` element tuples are joined by the given separators
3848 * to form the final markup which is appended to the given parent DOM node.
3850 * @param {Node} node
3851 * The parent DOM node to append the markup to. Any previous child elements
3854 * @param {Array<*>} items
3855 * An alternating array of labels and values. The label values will be
3856 * converted to plain strings, the values are used as-is and may be of
3857 * any type accepted by `LuCI.dom.content()`.
3859 * @param {*|Array<*>} [separators=[E('br')]]
3860 * A single value or an array of separator values to separate each
3861 * label/value pair with. The function will cycle through the separators
3862 * when joining the pairs. If omitted, the default separator is a sole HTML
3863 * `<br>` element. Separator values are used as-is and may be of any type
3864 * accepted by `LuCI.dom.content()`.
3867 * Returns the parent DOM node the formatted markup has been added to.
3869 itemlist: function(node
, items
, separators
) {
3872 if (!Array
.isArray(separators
))
3873 separators
= [ separators
|| E('br') ];
3875 for (var i
= 0; i
< items
.length
; i
+= 2) {
3876 if (items
[i
+1] !== null && items
[i
+1] !== undefined) {
3877 var sep
= separators
[(i
/2) % separators
.length
],
3880 children
.push(E('span', { class: 'nowrap' }, [
3881 items
[i
] ? E('strong', items
[i
] + ': ') : '',
3885 if ((i
+2) < items
.length
)
3886 children
.push(dom
.elem(sep
) ? sep
.cloneNode(true) : sep
);
3890 dom
.content(node
, children
);
3901 * The `tabs` class handles tab menu groups used throughout the view area.
3902 * It takes care of setting up tab groups, tracking their state and handling
3905 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3906 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3907 * external JavaScript, use `L.require("ui").then(...)` and access the
3908 * `tabs` property of the class instance value.
3910 tabs
: baseclass
.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3913 var groups
= [], prevGroup
= null, currGroup
= null;
3915 document
.querySelectorAll('[data-tab]').forEach(function(tab
) {
3916 var parent
= tab
.parentNode
;
3918 if (dom
.matches(tab
, 'li') && dom
.matches(parent
, 'ul.cbi-tabmenu'))
3921 if (!parent
.hasAttribute('data-tab-group'))
3922 parent
.setAttribute('data-tab-group', groups
.length
);
3924 currGroup
= +parent
.getAttribute('data-tab-group');
3926 if (currGroup
!== prevGroup
) {
3927 prevGroup
= currGroup
;
3929 if (!groups
[currGroup
])
3930 groups
[currGroup
] = [];
3933 groups
[currGroup
].push(tab
);
3936 for (var i
= 0; i
< groups
.length
; i
++)
3937 this.initTabGroup(groups
[i
]);
3939 document
.addEventListener('dependency-update', this.updateTabs
.bind(this));
3945 * Initializes a new tab group from the given tab pane collection.
3947 * This function cycles through the given tab pane DOM nodes, extracts
3948 * their tab IDs, titles and active states, renders a corresponding
3949 * tab menu and prepends it to the tab panes common parent DOM node.
3951 * The tab menu labels will be set to the value of the `data-tab-title`
3952 * attribute of each corresponding pane. The last pane with the
3953 * `data-tab-active` attribute set to `true` will be selected by default.
3955 * If no pane is marked as active, the first one will be preselected.
3958 * @memberof LuCI.ui.tabs
3959 * @param {Array<Node>|NodeList} panes
3960 * A collection of tab panes to build a tab group menu for. May be a
3961 * plain array of DOM nodes or a NodeList collection, such as the result
3962 * of a `querySelectorAll()` call or the `.childNodes` property of a
3965 initTabGroup: function(panes
) {
3966 if (typeof(panes
) != 'object' || !('length' in panes
) || panes
.length
=== 0)
3969 var menu
= E('ul', { 'class': 'cbi-tabmenu' }),
3970 group
= panes
[0].parentNode
,
3971 groupId
= +group
.getAttribute('data-tab-group'),
3974 if (group
.getAttribute('data-initialized') === 'true')
3977 for (var i
= 0, pane
; pane
= panes
[i
]; i
++) {
3978 var name
= pane
.getAttribute('data-tab'),
3979 title
= pane
.getAttribute('data-tab-title'),
3980 active
= pane
.getAttribute('data-tab-active') === 'true';
3982 menu
.appendChild(E('li', {
3983 'style': this.isEmptyPane(pane
) ? 'display:none' : null,
3984 'class': active
? 'cbi-tab' : 'cbi-tab-disabled',
3988 'click': this.switchTab
.bind(this)
3995 group
.parentNode
.insertBefore(menu
, group
);
3996 group
.setAttribute('data-initialized', true);
3998 if (selected
=== null) {
3999 selected
= this.getActiveTabId(panes
[0]);
4001 if (selected
< 0 || selected
>= panes
.length
|| this.isEmptyPane(panes
[selected
])) {
4002 for (var i
= 0; i
< panes
.length
; i
++) {
4003 if (!this.isEmptyPane(panes
[i
])) {
4010 menu
.childNodes
[selected
].classList
.add('cbi-tab');
4011 menu
.childNodes
[selected
].classList
.remove('cbi-tab-disabled');
4012 panes
[selected
].setAttribute('data-tab-active', 'true');
4014 this.setActiveTabId(panes
[selected
], selected
);
4017 requestAnimationFrame(L
.bind(function(pane
) {
4018 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', {
4019 detail
: { tab
: pane
.getAttribute('data-tab') }
4021 }, this, panes
[selected
]));
4023 this.updateTabs(group
);
4027 * Checks whether the given tab pane node is empty.
4030 * @memberof LuCI.ui.tabs
4031 * @param {Node} pane
4032 * The tab pane to check.
4034 * @returns {boolean}
4035 * Returns `true` if the pane is empty, else `false`.
4037 isEmptyPane: function(pane
) {
4038 return dom
.isEmpty(pane
, function(n
) { return n
.classList
.contains('cbi-tab-descr') });
4042 getPathForPane: function(pane
) {
4043 var path
= [], node
= null;
4045 for (node
= pane
? pane
.parentNode
: null;
4046 node
!= null && node
.hasAttribute
!= null;
4047 node
= node
.parentNode
)
4049 if (node
.hasAttribute('data-tab'))
4050 path
.unshift(node
.getAttribute('data-tab'));
4051 else if (node
.hasAttribute('data-section-id'))
4052 path
.unshift(node
.getAttribute('data-section-id'));
4055 return path
.join('/');
4059 getActiveTabState: function() {
4060 var page
= document
.body
.getAttribute('data-page'),
4061 state
= session
.getLocalData('tab');
4063 if (L
.isObject(state
) && state
.page
=== page
&& L
.isObject(state
.paths
))
4066 session
.setLocalData('tab', null);
4068 return { page
: page
, paths
: {} };
4072 getActiveTabId: function(pane
) {
4073 var path
= this.getPathForPane(pane
);
4074 return +this.getActiveTabState().paths
[path
] || 0;
4078 setActiveTabId: function(pane
, tabIndex
) {
4079 var path
= this.getPathForPane(pane
),
4080 state
= this.getActiveTabState();
4082 state
.paths
[path
] = tabIndex
;
4084 return session
.setLocalData('tab', state
);
4088 updateTabs: function(ev
, root
) {
4089 (root
|| document
).querySelectorAll('[data-tab-title]').forEach(L
.bind(function(pane
) {
4090 var menu
= pane
.parentNode
.previousElementSibling
,
4091 tab
= menu
? menu
.querySelector('[data-tab="%s"]'.format(pane
.getAttribute('data-tab'))) : null,
4092 n_errors
= pane
.querySelectorAll('.cbi-input-invalid').length
;
4097 if (this.isEmptyPane(pane
)) {
4098 tab
.style
.display
= 'none';
4099 tab
.classList
.remove('flash');
4101 else if (tab
.style
.display
=== 'none') {
4102 tab
.style
.display
= '';
4103 requestAnimationFrame(function() { tab
.classList
.add('flash') });
4107 tab
.setAttribute('data-errors', n_errors
);
4108 tab
.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors
));
4109 tab
.setAttribute('data-tooltip-style', 'error');
4112 tab
.removeAttribute('data-errors');
4113 tab
.removeAttribute('data-tooltip');
4119 switchTab: function(ev
) {
4120 var tab
= ev
.target
.parentNode
,
4121 name
= tab
.getAttribute('data-tab'),
4122 menu
= tab
.parentNode
,
4123 group
= menu
.nextElementSibling
,
4124 groupId
= +group
.getAttribute('data-tab-group'),
4127 ev
.preventDefault();
4129 if (!tab
.classList
.contains('cbi-tab-disabled'))
4132 menu
.querySelectorAll('[data-tab]').forEach(function(tab
) {
4133 tab
.classList
.remove('cbi-tab');
4134 tab
.classList
.remove('cbi-tab-disabled');
4136 tab
.getAttribute('data-tab') === name
? 'cbi-tab' : 'cbi-tab-disabled');
4139 group
.childNodes
.forEach(function(pane
) {
4140 if (dom
.matches(pane
, '[data-tab]')) {
4141 if (pane
.getAttribute('data-tab') === name
) {
4142 pane
.setAttribute('data-tab-active', 'true');
4143 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', { detail
: { tab
: name
} }));
4144 UI
.prototype.tabs
.setActiveTabId(pane
, index
);
4147 pane
.setAttribute('data-tab-active', 'false');
4157 * @typedef {Object} FileUploadReply
4160 * @property {string} name - Name of the uploaded file without directory components
4161 * @property {number} size - Size of the uploaded file in bytes
4162 * @property {string} checksum - The MD5 checksum of the received file data
4163 * @property {string} sha256sum - The SHA256 checksum of the received file data
4167 * Display a modal file upload prompt.
4169 * This function opens a modal dialog prompting the user to select and
4170 * upload a file to a predefined remote destination path.
4172 * @param {string} path
4173 * The remote file path to upload the local file to.
4175 * @param {Node} [progressStatusNode]
4176 * An optional DOM text node whose content text is set to the progress
4177 * percentage value during file upload.
4179 * @returns {Promise<LuCI.ui.FileUploadReply>}
4180 * Returns a promise resolving to a file upload status object on success
4181 * or rejecting with an error in case the upload failed or has been
4182 * cancelled by the user.
4184 uploadFile: function(path
, progressStatusNode
) {
4185 return new Promise(function(resolveFn
, rejectFn
) {
4186 UI
.prototype.showModal(_('Uploading file…'), [
4187 E('p', _('Please select the file to upload.')),
4188 E('div', { 'style': 'display:flex' }, [
4189 E('div', { 'class': 'left', 'style': 'flex:1' }, [
4192 style
: 'display:none',
4193 change: function(ev
) {
4194 var modal
= dom
.parent(ev
.target
, '.modal'),
4195 body
= modal
.querySelector('p'),
4196 upload
= modal
.querySelector('.cbi-button-action.important'),
4197 file
= ev
.currentTarget
.files
[0];
4204 E('li', {}, [ '%s: %s'.format(_('Name'), file
.name
.replace(/^.*[\\\/]/, '')) ]),
4205 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file
.size
) ])
4209 upload
.disabled
= false;
4215 'click': function(ev
) {
4216 ev
.target
.previousElementSibling
.click();
4218 }, [ _('Browse…') ])
4220 E('div', { 'class': 'right', 'style': 'flex:1' }, [
4223 'click': function() {
4224 UI
.prototype.hideModal();
4225 rejectFn(new Error(_('Upload has been cancelled')));
4227 }, [ _('Cancel') ]),
4230 'class': 'btn cbi-button-action important',
4232 'click': function(ev
) {
4233 var input
= dom
.parent(ev
.target
, '.modal').querySelector('input[type="file"]');
4235 if (!input
.files
[0])
4238 var progress
= E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
4240 UI
.prototype.showModal(_('Uploading file…'), [ progress
]);
4242 var data
= new FormData();
4244 data
.append('sessionid', rpc
.getSessionID());
4245 data
.append('filename', path
);
4246 data
.append('filedata', input
.files
[0]);
4248 var filename
= input
.files
[0].name
;
4250 request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
4252 progress: function(pev
) {
4253 var percent
= (pev
.loaded
/ pev
.total
) * 100;
4255 if (progressStatusNode
)
4256 progressStatusNode
.data
= '%.2f%%'.format(percent
);
4258 progress
.setAttribute('title', '%.2f%%'.format(percent
));
4259 progress
.firstElementChild
.style
.width
= '%.2f%%'.format(percent
);
4261 }).then(function(res
) {
4262 var reply
= res
.json();
4264 UI
.prototype.hideModal();
4266 if (L
.isObject(reply
) && reply
.failure
) {
4267 UI
.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply
.message
)));
4268 rejectFn(new Error(reply
.failure
));
4271 reply
.name
= filename
;
4275 UI
.prototype.hideModal();
4287 * Perform a device connectivity test.
4289 * Attempt to fetch a well known resource from the remote device via HTTP
4290 * in order to test connectivity. This function is mainly useful to wait
4291 * for the router to come back online after a reboot or reconfiguration.
4293 * @param {string} [proto=http]
4294 * The protocol to use for fetching the resource. May be either `http`
4295 * (the default) or `https`.
4297 * @param {string} [ipaddr=window.location.host]
4298 * Override the host address to probe. By default the current host as seen
4299 * in the address bar is probed.
4301 * @returns {Promise<Event>}
4302 * Returns a promise resolving to a `load` event in case the device is
4303 * reachable or rejecting with an `error` event in case it is not reachable
4304 * or rejecting with `null` when the connectivity check timed out.
4306 pingDevice: function(proto
, ipaddr
) {
4307 var target
= '%s://%s%s?%s'.format(proto
|| 'http', ipaddr
|| window
.location
.host
, L
.resource('icons/loading.gif'), Math
.random());
4309 return new Promise(function(resolveFn
, rejectFn
) {
4310 var img
= new Image();
4312 img
.onload
= resolveFn
;
4313 img
.onerror
= rejectFn
;
4315 window
.setTimeout(rejectFn
, 1000);
4322 * Wait for device to come back online and reconnect to it.
4324 * Poll each given hostname or IP address and navigate to it as soon as
4325 * one of the addresses becomes reachable.
4327 * @param {...string} [hosts=[window.location.host]]
4328 * The list of IP addresses and host names to check for reachability.
4329 * If omitted, the current value of `window.location.host` is used by
4332 awaitReconnect: function(/* ... */) {
4333 var ipaddrs
= arguments
.length
? arguments
: [ window
.location
.host
];
4335 window
.setTimeout(L
.bind(function() {
4336 poll
.add(L
.bind(function() {
4337 var tasks
= [], reachable
= false;
4339 for (var i
= 0; i
< 2; i
++)
4340 for (var j
= 0; j
< ipaddrs
.length
; j
++)
4341 tasks
.push(this.pingDevice(i
? 'https' : 'http', ipaddrs
[j
])
4342 .then(function(ev
) { reachable
= ev
.target
.src
.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
4344 return Promise
.all(tasks
).then(function() {
4347 window
.location
= reachable
;
4360 * The `changes` class encapsulates logic for visualizing, applying,
4361 * confirming and reverting staged UCI changesets.
4363 * This class is automatically instantiated as part of `LuCI.ui`. To use it
4364 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
4365 * external JavaScript, use `L.require("ui").then(...)` and access the
4366 * `changes` property of the class instance value.
4368 changes
: baseclass
.singleton(/* @lends LuCI.ui.changes.prototype */ {
4370 if (!L
.env
.sessionid
)
4373 return uci
.changes().then(L
.bind(this.renderChangeIndicator
, this));
4377 * Set the change count indicator.
4379 * This function updates or hides the UCI change count indicator,
4380 * depending on the passed change count. When the count is greater
4381 * than 0, the change indicator is displayed or updated, otherwise it
4385 * @memberof LuCI.ui.changes
4387 * The number of changes to indicate.
4389 setIndicator: function(n
) {
4391 UI
.prototype.showIndicator('uci-changes',
4392 '%s: %d'.format(_('Unsaved Changes'), n
),
4393 L
.bind(this.displayChanges
, this));
4396 UI
.prototype.hideIndicator('uci-changes');
4401 * Update the change count indicator.
4403 * This function updates the UCI change count indicator from the given
4404 * UCI changeset structure.
4407 * @memberof LuCI.ui.changes
4408 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
4409 * The UCI changeset to count.
4411 renderChangeIndicator: function(changes
) {
4414 for (var config
in changes
)
4415 if (changes
.hasOwnProperty(config
))
4416 n_changes
+= changes
[config
].length
;
4418 this.changes
= changes
;
4419 this.setIndicator(n_changes
);
4424 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
4425 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
4426 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
4427 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
4428 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
4429 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
4430 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
4431 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
4432 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
4433 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
4437 * Display the current changelog.
4439 * Open a modal dialog visualizing the currently staged UCI changes
4440 * and offer options to revert or apply the shown changes.
4443 * @memberof LuCI.ui.changes
4445 displayChanges: function() {
4446 var list
= E('div', { 'class': 'uci-change-list' }),
4447 dlg
= UI
.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
4448 E('div', { 'class': 'cbi-section' }, [
4449 E('strong', _('Legend:')),
4450 E('div', { 'class': 'uci-change-legend' }, [
4451 E('div', { 'class': 'uci-change-legend-label' }, [
4452 E('ins', ' '), ' ', _('Section added') ]),
4453 E('div', { 'class': 'uci-change-legend-label' }, [
4454 E('del', ' '), ' ', _('Section removed') ]),
4455 E('div', { 'class': 'uci-change-legend-label' }, [
4456 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
4457 E('div', { 'class': 'uci-change-legend-label' }, [
4458 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
4460 E('div', { 'class': 'right' }, [
4463 'click': UI
.prototype.hideModal
4464 }, [ _('Close') ]), ' ',
4465 new UIComboButton('0', {
4466 0: [ _('Save & Apply') ],
4467 1: [ _('Apply unchecked') ]
4470 0: 'btn cbi-button cbi-button-positive important',
4471 1: 'btn cbi-button cbi-button-negative important'
4473 click
: L
.bind(function(ev
, mode
) { this.apply(mode
== '0') }, this)
4476 'class': 'cbi-button cbi-button-reset',
4477 'click': L
.bind(this.revert
, this)
4478 }, [ _('Revert') ])])])
4481 for (var config
in this.changes
) {
4482 if (!this.changes
.hasOwnProperty(config
))
4485 list
.appendChild(E('h5', '# /etc/config/%s'.format(config
)));
4487 for (var i
= 0, added
= null; i
< this.changes
[config
].length
; i
++) {
4488 var chg
= this.changes
[config
][i
],
4489 tpl
= this.changeTemplates
['%s-%d'.format(chg
[0], chg
.length
)];
4491 list
.appendChild(E(tpl
.replace(/%([01234])/g, function(m0
, m1
) {
4497 if (added
!= null && chg
[1] == added
[0])
4498 return '@' + added
[1] + '[-1]';
4503 return "'%h'".format(chg
[3].replace(/'/g, "'\"'\"'"));
4510 if (chg[0] == 'add')
4511 added = [ chg[1], chg[2] ];
4515 list.appendChild(E('br'));
4516 dlg.classList.add('uci-dialog');
4520 displayStatus: function(type, content) {
4522 var message = UI.prototype.showModal('', '');
4524 message.classList.add('alert-message');
4525 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4528 dom.content(message, content);
4530 if (!this.was_polling) {
4531 this.was_polling = request.poll.active();
4532 request.poll.stop();
4536 UI.prototype.hideModal();
4538 if (this.was_polling)
4539 request.poll.start();
4544 checkConnectivityAffected: function() {
4545 return L.resolveDefault(fs.exec_direct('/usr/libexec/luci-peeraddr', null, 'json')).then(L.bind(function(info) {
4546 if (L.isObject(info) && Array.isArray(info.inbound_interfaces)) {
4547 for (var i = 0; i < info.inbound_interfaces.length; i++) {
4548 var iif = info.inbound_interfaces[i];
4550 for (var j = 0; this.changes && this.changes.network && j < this.changes.network.length; j++) {
4551 var chg = this.changes.network[j];
4553 if (chg[0] == 'set' && chg[1] == iif &&
4554 ((chg[2] == 'disabled' && chg[3] == '1') || chg[2] == 'proto' || chg[2] == 'ipaddr' || chg[2] == 'netmask'))
4565 rollback: function(checked) {
4567 this.displayStatus('warning spinning',
4568 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4569 .format(L.env.apply_rollback)));
4571 var call = function(r, data, duration) {
4572 if (r.status === 204) {
4573 UI.prototype.changes.displayStatus('warning', [
4574 E('h4', _('Configuration changes have been rolled back!')),
4575 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)),
4576 E('div', { 'class': 'right' }, [
4579 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4580 }, [ _('Dismiss') ]), ' ',
4582 'class': 'btn cbi-button-action important',
4583 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4584 }, [ _('Revert changes') ]), ' ',
4586 'class': 'btn cbi-button-negative important',
4587 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4588 }, [ _('Apply unchecked') ])
4595 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4596 window.setTimeout(function() {
4597 request.request(L.url('admin/uci/confirm'), {
4599 timeout: L.env.apply_timeout * 1000,
4600 query: { sid: L.env.sessionid, token: L.env.token }
4601 }).then(call, call.bind(null, { status: 0 }, null, 0));
4605 call({ status: 0 });
4608 this.displayStatus('warning', [
4609 E('h4', _('Device unreachable!')),
4610 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.'))
4616 confirm: function(checked, deadline, override_token) {
4618 var ts = Date.now();
4620 this.displayStatus('notice');
4623 this.confirm_auth = { token: override_token };
4625 var call = function(r, data, duration) {
4626 if (Date.now() >= deadline) {
4627 window.clearTimeout(tt);
4628 UI.prototype.changes.rollback(checked);
4631 else if (r && (r.status === 200 || r.status === 204)) {
4632 document.dispatchEvent(new CustomEvent('uci-applied'));
4634 UI.prototype.changes.setIndicator(0);
4635 UI.prototype.changes.displayStatus('notice',
4636 E('p', _('Configuration changes applied.')));
4638 window.clearTimeout(tt);
4639 window.setTimeout(function() {
4640 //UI.prototype.changes.displayStatus(false);
4641 window.location = window.location.href.split('#')[0];
4642 }, L.env.apply_display * 1000);
4647 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4648 window.setTimeout(function() {
4649 request.request(L.url('admin/uci/confirm'), {
4651 timeout: L.env.apply_timeout * 1000,
4652 query: UI.prototype.changes.confirm_auth
4653 }).then(call, call);
4657 var tick = function() {
4658 var now = Date.now();
4660 UI.prototype.changes.displayStatus('notice spinning',
4661 E('p', _('Applying configuration changes… %ds')
4662 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4664 if (now >= deadline)
4667 tt = window.setTimeout(tick, 1000 - (now - ts));
4673 /* wait a few seconds for the settings to become effective */
4674 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4678 * Apply the staged configuration changes.
4680 * Start applying staged configuration changes and open a modal dialog
4681 * with a progress indication to prevent interaction with the view
4682 * during the apply process. The modal dialog will be automatically
4683 * closed and the current view reloaded once the apply process is
4687 * @memberof LuCI.ui.changes
4688 * @param {boolean} [checked=false]
4689 * Whether to perform a checked (`true`) configuration apply or an
4690 * unchecked (`false`) one.
4692 * In case of a checked apply, the configuration changes must be
4693 * confirmed within a specific time interval, otherwise the device
4694 * will begin to roll back the changes in order to restore the previous
4697 apply: function(checked) {
4698 this.displayStatus('notice spinning',
4699 E('p', _('Starting configuration apply…')));
4701 (new Promise(function(resolveFn, rejectFn) {
4703 return resolveFn(false);
4705 UI.prototype.changes.checkConnectivityAffected().then(function(affected) {
4707 return resolveFn(true);
4709 UI.prototype.changes.displayStatus('warning', [
4710 E('h4', _('Connectivity change')),
4711 E('p', _('The network access to this device could be interrupted by changing settings of the "%h
" interface.').format(affected)),
4712 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)),
4713 E('div', { 'class': 'right' }, [
4717 }, [ _('Cancel') ]), ' ',
4719 'class': 'btn cbi-button-action important',
4720 'click': resolveFn.bind(null, true)
4721 }, [ _('Apply with revert after connectivity loss') ]), ' ',
4723 'class': 'btn cbi-button-negative important',
4724 'click': resolveFn.bind(null, false)
4725 }, [ _('Apply and keep settings') ])
4729 })).then(function(checked) {
4730 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4732 query: { sid: L.env.sessionid, token: L.env.token }
4733 }).then(function(r) {
4734 if (r.status === (checked ? 200 : 204)) {
4735 var tok = null; try { tok = r.json(); } catch(e) {}
4736 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4737 UI.prototype.changes.confirm_auth = tok;
4739 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4741 else if (checked && r.status === 204) {
4742 UI.prototype.changes.displayStatus('notice',
4743 E('p', _('There are no changes to apply')));
4745 window.setTimeout(function() {
4746 UI.prototype.changes.displayStatus(false);
4747 }, L.env.apply_display * 1000);
4750 UI.prototype.changes.displayStatus('warning',
4751 E('p', _('Apply request failed with status <code>%h</code>')
4752 .format(r.responseText || r.statusText || r.status)));
4754 window.setTimeout(function() {
4755 UI.prototype.changes.displayStatus(false);
4756 }, L.env.apply_display * 1000);
4759 }, this.displayStatus.bind(this, false));
4763 * Revert the staged configuration changes.
4765 * Start reverting staged configuration changes and open a modal dialog
4766 * with a progress indication to prevent interaction with the view
4767 * during the revert process. The modal dialog will be automatically
4768 * closed and the current view reloaded once the revert process is
4772 * @memberof LuCI.ui.changes
4774 revert: function() {
4775 this.displayStatus('notice spinning',
4776 E('p', _('Reverting configuration…')));
4778 request.request(L.url('admin/uci/revert'), {
4780 query: { sid: L.env.sessionid, token: L.env.token }
4781 }).then(function(r) {
4782 if (r.status === 200) {
4783 document.dispatchEvent(new CustomEvent('uci-reverted'));
4785 UI.prototype.changes.setIndicator(0);
4786 UI.prototype.changes.displayStatus('notice',
4787 E('p', _('Changes have been reverted.')));
4789 window.setTimeout(function() {
4790 //UI.prototype.changes.displayStatus(false);
4791 window.location = window.location.href.split('#')[0];
4792 }, L.env.apply_display * 1000);
4795 UI.prototype.changes.displayStatus('warning',
4796 E('p', _('Revert request failed with status <code>%h</code>')
4797 .format(r.statusText || r.status)));
4799 window.setTimeout(function() {
4800 UI.prototype.changes.displayStatus(false);
4801 }, L.env.apply_display * 1000);
4808 * Add validation constraints to an input element.
4810 * Compile the given type expression and optional validator function into
4811 * a validation function and bind it to the specified input element events.
4813 * @param {Node} field
4814 * The DOM input element node to bind the validation constraints to.
4816 * @param {string} type
4817 * The datatype specification to describe validation constraints.
4818 * Refer to the `LuCI.validation` class documentation for details.
4820 * @param {boolean} [optional=false]
4821 * Specifies whether empty values are allowed (`true`) or not (`false`).
4822 * If an input element is not marked optional it must not be empty,
4823 * otherwise it will be marked as invalid.
4825 * @param {function} [vfunc]
4826 * Specifies a custom validation function which is invoked after the
4827 * other validation constraints are applied. The validation must return
4828 * `true` to accept the passed value. Any other return type is converted
4829 * to a string and treated as validation error message.
4831 * @param {...string} [events=blur, keyup]
4832 * The list of events to bind. Each received event will trigger a field
4833 * validation. If omitted, the `keyup` and `blur` events are bound by
4836 * @returns {function}
4837 * Returns the compiled validator function which can be used to manually
4838 * trigger field validation or to bind it to further events.
4840 * @see LuCI.validation
4842 addValidator: function(field, type, optional, vfunc /*, ... */) {
4846 var events = this.varargs(arguments, 3);
4847 if (events.length == 0)
4848 events.push('blur', 'keyup');
4851 var cbiValidator = validation.create(field, type, optional, vfunc),
4852 validatorFn = cbiValidator.validate.bind(cbiValidator);
4854 for (var i = 0; i < events.length; i++)
4855 field.addEventListener(events[i], validatorFn);
4865 * Create a pre-bound event handler function.
4867 * Generate and bind a function suitable for use in event handlers. The
4868 * generated function automatically disables the event source element
4869 * and adds an active indication to it by adding appropriate CSS classes.
4871 * It will also await any promises returned by the wrapped function and
4872 * re-enable the source element after the promises ran to completion.
4875 * The `this` context to use for the wrapped function.
4877 * @param {function|string} fn
4878 * Specifies the function to wrap. In case of a function value, the
4879 * function is used as-is. If a string is specified instead, it is looked
4880 * up in `ctx` to obtain the function to wrap. In both cases the bound
4881 * function will be invoked with `ctx` as `this` context
4883 * @param {...*} extra_args
4884 * Any further parameter as passed as-is to the bound event handler
4885 * function in the same order as passed to `createHandlerFn()`.
4887 * @returns {function|null}
4888 * Returns the pre-bound handler function which is suitable to be passed
4889 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4890 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4891 * valid function value.
4893 createHandlerFn: function(ctx, fn /*, ... */) {
4894 if (typeof(fn) == 'string')
4897 if (typeof(fn) != 'function')
4900 var arg_offset = arguments.length - 2;
4902 return Function.prototype.bind.apply(function() {
4903 var t = arguments[arg_offset].currentTarget;
4905 t.classList.add('spinning');
4911 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4912 t.classList.remove('spinning');
4915 }, this.varargs(arguments, 2, ctx));
4919 * Load specified view class path and set it up.
4921 * Transforms the given view path into a class name, requires it
4922 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4923 * resulting class instance is a descendant of
4924 * [LuCI.view]{@link LuCI.view}.
4926 * By instantiating the view class, its corresponding contents are
4927 * rendered and included into the view area. Any runtime errors are
4928 * caught and rendered using [LuCI.error()]{@link LuCI#error}.
4930 * @param {string} path
4931 * The view path to render.
4933 * @returns {Promise<LuCI.view>}
4934 * Returns a promise resolving to the loaded view instance.
4936 instantiateView: function(path) {
4937 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4939 return L.require(className).then(function(view) {
4940 if (!(view instanceof View))
4941 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4944 }).catch(function(err) {
4945 dom.content(document.querySelector('#view'), null);
4954 AbstractElement: UIElement,
4957 Textfield: UITextfield,
4958 Textarea: UITextarea,
4959 Checkbox: UICheckbox,
4961 Dropdown: UIDropdown,
4962 DynamicList: UIDynamicList,
4963 Combobox: UICombobox,
4964 ComboButton: UIComboButton,
4965 Hiddenfield: UIHiddenfield,
4966 FileUpload: UIFileUpload