15 tooltipTimeout
= null;
18 * @class AbstractElement
23 * The `AbstractElement` class serves as abstract base for the different widgets
24 * implemented by `LuCI.ui`. It provides the common logic for getting and
25 * setting values, for checking the validity state and for wiring up required
28 * UI widget instances are usually not supposed to be created by view code
29 * directly, instead they're implicitely created by `LuCI.form` when
30 * instantiating CBI forms.
32 * This class is automatically instantiated as part of `LuCI.ui`. To use it
33 * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
34 * it in external JavaScript, use `L.require("ui").then(...)` and access the
35 * `AbstractElement` property of the class instance value.
37 var UIElement
= baseclass
.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
39 * @typedef {Object} InitOptions
40 * @memberof LuCI.ui.AbstractElement
42 * @property {string} [id]
43 * Specifies the widget ID to use. It will be used as HTML `id` attribute
44 * on the toplevel widget DOM node.
46 * @property {string} [name]
47 * Specifies the widget name which is set as HTML `name` attribute on the
48 * corresponding `<input>` element.
50 * @property {boolean} [optional=true]
51 * Specifies whether the input field allows empty values.
53 * @property {string} [datatype=string]
54 * An expression describing the input data validation constraints.
55 * It defaults to `string` which will allow any value.
56 * See {@link LuCI.validation} for details on the expression format.
58 * @property {function} [validator]
59 * Specifies a custom validator function which is invoked after the
60 * standard validation constraints are checked. The function should return
61 * `true` to accept the given input value. Any other return value type is
62 * converted to a string and treated as validation error message.
64 * @property {boolean} [disabled=false]
65 * Specifies whether the widget should be rendered in disabled state
66 * (`true`) or not (`false`). Disabled widgets cannot be interacted with
67 * and are displayed in a slightly faded style.
71 * Read the current value of the input widget.
74 * @memberof LuCI.ui.AbstractElement
75 * @returns {string|string[]|null}
76 * The current value of the input element. For simple inputs like text
77 * fields or selects, the return value type will be a - possibly empty -
78 * string. Complex widgets such as `DynamicList` instances may result in
79 * an array of strings or `null` for unset values.
81 getValue: function() {
82 if (dom
.matches(this.node
, 'select') || dom
.matches(this.node
, 'input'))
83 return this.node
.value
;
89 * Set the current value of the input widget.
92 * @memberof LuCI.ui.AbstractElement
93 * @param {string|string[]|null} value
94 * The value to set the input element to. For simple inputs like text
95 * fields or selects, the value should be a - possibly empty - string.
96 * Complex widgets such as `DynamicList` instances may accept string array
99 setValue: function(value
) {
100 if (dom
.matches(this.node
, 'select') || dom
.matches(this.node
, 'input'))
101 this.node
.value
= value
;
105 * Set the current placeholder value of the input widget.
108 * @memberof LuCI.ui.AbstractElement
109 * @param {string|string[]|null} value
110 * The placeholder to set for the input element. Only applicable to text
111 * inputs, not to radio buttons, selects or similar.
113 setPlaceholder: function(value
) {
114 var node
= this.node
? this.node
.querySelector('input,textarea') : null;
116 switch (node
.getAttribute('type') || 'text') {
122 if (value
!= null && value
!= '')
123 node
.setAttribute('placeholder', value
);
125 node
.removeAttribute('placeholder');
131 * Check whether the input value was altered by the user.
134 * @memberof LuCI.ui.AbstractElement
136 * Returns `true` if the input value has been altered by the user or
137 * `false` if it is unchaged. Note that if the user modifies the initial
138 * value and changes it back to the original state, it is still reported
141 isChanged: function() {
142 return (this.node
? this.node
.getAttribute('data-changed') : null) == 'true';
146 * Check whether the current input value is valid.
149 * @memberof LuCI.ui.AbstractElement
151 * Returns `true` if the current input value is valid or `false` if it does
152 * not meet the validation constraints.
154 isValid: function() {
155 return (this.validState
!== false);
159 * Returns the current validation error
162 * @memberof LuCI.ui.AbstractElement
164 * The validation error at this time
166 getValidationError: function() {
167 return this.validationError
|| '';
171 * Force validation of the current input value.
173 * Usually input validation is automatically triggered by various DOM events
174 * bound to the input widget. In some cases it is required though to manually
175 * trigger validation runs, e.g. when programmatically altering values.
178 * @memberof LuCI.ui.AbstractElement
180 triggerValidation: function() {
181 if (typeof(this.vfunc
) != 'function')
184 var wasValid
= this.isValid();
188 return (wasValid
!= this.isValid());
192 * Dispatch a custom (synthetic) event in response to received events.
194 * Sets up event handlers on the given target DOM node for the given event
195 * names that dispatch a custom event of the given type to the widget root
198 * The primary purpose of this function is to set up a series of custom
199 * uniform standard events such as `widget-update`, `validation-success`,
200 * `validation-failure` etc. which are triggered by various different
201 * widget specific native DOM events.
204 * @memberof LuCI.ui.AbstractElement
205 * @param {Node} targetNode
206 * Specifies the DOM node on which the native event listeners should be
209 * @param {string} synevent
210 * The name of the custom event to dispatch to the widget root DOM node.
212 * @param {string[]} events
213 * The native DOM events for which event handlers should be registered.
215 registerEvents: function(targetNode
, synevent
, events
) {
216 var dispatchFn
= L
.bind(function(ev
) {
217 this.node
.dispatchEvent(new CustomEvent(synevent
, { bubbles
: true }));
220 for (var i
= 0; i
< events
.length
; i
++)
221 targetNode
.addEventListener(events
[i
], dispatchFn
);
225 * Setup listeners for native DOM events that may update the widget value.
227 * Sets up event handlers on the given target DOM node for the given event
228 * names which may cause the input value to update, such as `keyup` or
229 * `onclick` events. In contrast to change events, such update events will
230 * trigger input value validation.
233 * @memberof LuCI.ui.AbstractElement
234 * @param {Node} targetNode
235 * Specifies the DOM node on which the event listeners should be registered.
237 * @param {...string} events
238 * The DOM events for which event handlers should be registered.
240 setUpdateEvents: function(targetNode
/*, ... */) {
241 var datatype
= this.options
.datatype
,
242 optional
= this.options
.hasOwnProperty('optional') ? this.options
.optional
: true,
243 validate
= this.options
.validate
,
244 events
= this.varargs(arguments
, 1);
246 this.registerEvents(targetNode
, 'widget-update', events
);
248 if (!datatype
&& !validate
)
251 this.vfunc
= UI
.prototype.addValidator
.apply(UI
.prototype, [
252 targetNode
, datatype
|| 'string',
256 this.node
.addEventListener('validation-success', L
.bind(function(ev
) {
257 this.validState
= true;
258 this.validationError
= '';
261 this.node
.addEventListener('validation-failure', L
.bind(function(ev
) {
262 this.validState
= false;
263 this.validationError
= ev
.detail
.message
;
268 * Setup listeners for native DOM events that may change the widget value.
270 * Sets up event handlers on the given target DOM node for the given event
271 * names which may cause the input value to change completely, such as
272 * `change` events in a select menu. In contrast to update events, such
273 * change events will not trigger input value validation but they may cause
274 * field dependencies to get re-evaluated and will mark the input widget
278 * @memberof LuCI.ui.AbstractElement
279 * @param {Node} targetNode
280 * Specifies the DOM node on which the event listeners should be registered.
282 * @param {...string} events
283 * The DOM events for which event handlers should be registered.
285 setChangeEvents: function(targetNode
/*, ... */) {
286 var tag_changed
= L
.bind(function(ev
) { this.setAttribute('data-changed', true) }, this.node
);
288 for (var i
= 1; i
< arguments
.length
; i
++)
289 targetNode
.addEventListener(arguments
[i
], tag_changed
);
291 this.registerEvents(targetNode
, 'widget-change', this.varargs(arguments
, 1));
295 * Render the widget, setup event listeners and return resulting markup.
298 * @memberof LuCI.ui.AbstractElement
301 * Returns a DOM Node or DocumentFragment containing the rendered
304 render: function() {}
308 * Instantiate a text input widget.
310 * @constructor Textfield
312 * @augments LuCI.ui.AbstractElement
316 * The `Textfield` class implements a standard single line text input field.
318 * UI widget instances are usually not supposed to be created by view code
319 * directly, instead they're implicitely created by `LuCI.form` when
320 * instantiating CBI forms.
322 * This class is automatically instantiated as part of `LuCI.ui`. To use it
323 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
324 * external JavaScript, use `L.require("ui").then(...)` and access the
325 * `Textfield` property of the class instance value.
327 * @param {string} [value=null]
328 * The initial input value.
330 * @param {LuCI.ui.Textfield.InitOptions} [options]
331 * Object describing the widget specific options to initialize the input.
333 var UITextfield
= UIElement
.extend(/** @lends LuCI.ui.Textfield.prototype */ {
335 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
336 * the following properties are recognized:
338 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
339 * @memberof LuCI.ui.Textfield
341 * @property {boolean} [password=false]
342 * Specifies whether the input should be rendered as concealed password field.
344 * @property {boolean} [readonly=false]
345 * Specifies whether the input widget should be rendered readonly.
347 * @property {number} [maxlength]
348 * Specifies the HTML `maxlength` attribute to set on the corresponding
349 * `<input>` element. Note that this a legacy property that exists for
350 * compatibility reasons. It is usually better to `maxlength(N)` validation
353 * @property {string} [placeholder]
354 * Specifies the HTML `placeholder` attribute which is displayed when the
355 * corresponding `<input>` element is empty.
357 __init__: function(value
, options
) {
359 this.options
= Object
.assign({
367 var frameEl
= E('div', { 'id': this.options
.id
});
368 var inputEl
= E('input', {
369 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
370 'name': this.options
.name
,
372 'class': this.options
.password
? 'cbi-input-password' : 'cbi-input-text',
373 'readonly': this.options
.readonly
? '' : null,
374 'disabled': this.options
.disabled
? '' : null,
375 'maxlength': this.options
.maxlength
,
376 'placeholder': this.options
.placeholder
,
380 if (this.options
.password
) {
381 frameEl
.appendChild(E('div', { 'class': 'control-group' }, [
384 'class': 'cbi-button cbi-button-neutral',
385 'title': _('Reveal/hide password'),
386 'aria-label': _('Reveal/hide password'),
387 'click': function(ev
) {
388 var e
= this.previousElementSibling
;
389 e
.type
= (e
.type
=== 'password') ? 'text' : 'password';
395 window
.requestAnimationFrame(function() { inputEl
.type
= 'password' });
398 frameEl
.appendChild(inputEl
);
401 return this.bind(frameEl
);
405 bind: function(frameEl
) {
406 var inputEl
= frameEl
.querySelector('input');
410 this.setUpdateEvents(inputEl
, 'keyup', 'blur');
411 this.setChangeEvents(inputEl
, 'change');
413 dom
.bindClassInstance(frameEl
, this);
419 getValue: function() {
420 var inputEl
= this.node
.querySelector('input');
421 return inputEl
.value
;
425 setValue: function(value
) {
426 var inputEl
= this.node
.querySelector('input');
427 inputEl
.value
= value
;
432 * Instantiate a textarea widget.
434 * @constructor Textarea
436 * @augments LuCI.ui.AbstractElement
440 * The `Textarea` class implements a multiline text area input field.
442 * UI widget instances are usually not supposed to be created by view code
443 * directly, instead they're implicitely created by `LuCI.form` when
444 * instantiating CBI forms.
446 * This class is automatically instantiated as part of `LuCI.ui`. To use it
447 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
448 * external JavaScript, use `L.require("ui").then(...)` and access the
449 * `Textarea` property of the class instance value.
451 * @param {string} [value=null]
452 * The initial input value.
454 * @param {LuCI.ui.Textarea.InitOptions} [options]
455 * Object describing the widget specific options to initialize the input.
457 var UITextarea
= UIElement
.extend(/** @lends LuCI.ui.Textarea.prototype */ {
459 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
460 * the following properties are recognized:
462 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
463 * @memberof LuCI.ui.Textarea
465 * @property {boolean} [readonly=false]
466 * Specifies whether the input widget should be rendered readonly.
468 * @property {string} [placeholder]
469 * Specifies the HTML `placeholder` attribute which is displayed when the
470 * corresponding `<textarea>` element is empty.
472 * @property {boolean} [monospace=false]
473 * Specifies whether a monospace font should be forced for the textarea
476 * @property {number} [cols]
477 * Specifies the HTML `cols` attribute to set on the corresponding
478 * `<textarea>` element.
480 * @property {number} [rows]
481 * Specifies the HTML `rows` attribute to set on the corresponding
482 * `<textarea>` element.
484 * @property {boolean} [wrap=false]
485 * Specifies whether the HTML `wrap` attribute should be set.
487 __init__: function(value
, options
) {
489 this.options
= Object
.assign({
499 var style
= !this.options
.cols
? 'width:100%' : null,
500 frameEl
= E('div', { 'id': this.options
.id
, 'style': style
}),
501 value
= (this.value
!= null) ? String(this.value
) : '';
503 frameEl
.appendChild(E('textarea', {
504 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
505 'name': this.options
.name
,
506 'class': 'cbi-input-textarea',
507 'readonly': this.options
.readonly
? '' : null,
508 'disabled': this.options
.disabled
? '' : null,
509 'placeholder': this.options
.placeholder
,
511 'cols': this.options
.cols
,
512 'rows': this.options
.rows
,
513 'wrap': this.options
.wrap
? '' : null
516 if (this.options
.monospace
)
517 frameEl
.firstElementChild
.style
.fontFamily
= 'monospace';
519 return this.bind(frameEl
);
523 bind: function(frameEl
) {
524 var inputEl
= frameEl
.firstElementChild
;
528 this.setUpdateEvents(inputEl
, 'keyup', 'blur');
529 this.setChangeEvents(inputEl
, 'change');
531 dom
.bindClassInstance(frameEl
, this);
537 getValue: function() {
538 return this.node
.firstElementChild
.value
;
542 setValue: function(value
) {
543 this.node
.firstElementChild
.value
= value
;
548 * Instantiate a checkbox widget.
550 * @constructor Checkbox
552 * @augments LuCI.ui.AbstractElement
556 * The `Checkbox` class implements a simple checkbox input field.
558 * UI widget instances are usually not supposed to be created by view code
559 * directly, instead they're implicitely created by `LuCI.form` when
560 * instantiating CBI forms.
562 * This class is automatically instantiated as part of `LuCI.ui`. To use it
563 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
564 * external JavaScript, use `L.require("ui").then(...)` and access the
565 * `Checkbox` property of the class instance value.
567 * @param {string} [value=null]
568 * The initial input value.
570 * @param {LuCI.ui.Checkbox.InitOptions} [options]
571 * Object describing the widget specific options to initialize the input.
573 var UICheckbox
= UIElement
.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
575 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
576 * the following properties are recognized:
578 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
579 * @memberof LuCI.ui.Checkbox
581 * @property {string} [value_enabled=1]
582 * Specifies the value corresponding to a checked checkbox.
584 * @property {string} [value_disabled=0]
585 * Specifies the value corresponding to an unchecked checkbox.
587 * @property {string} [hiddenname]
588 * Specifies the HTML `name` attribute of the hidden input backing the
589 * checkbox. This is a legacy property existing for compatibility reasons,
590 * it is required for HTML based form submissions.
592 __init__: function(value
, options
) {
594 this.options
= Object
.assign({
602 var id
= 'cb%08x'.format(Math
.random() * 0xffffffff);
603 var frameEl
= E('div', {
604 'id': this.options
.id
,
605 'class': 'cbi-checkbox'
608 if (this.options
.hiddenname
)
609 frameEl
.appendChild(E('input', {
611 'name': this.options
.hiddenname
,
615 frameEl
.appendChild(E('input', {
617 'name': this.options
.name
,
619 'value': this.options
.value_enabled
,
620 'checked': (this.value
== this.options
.value_enabled
) ? '' : null,
621 'disabled': this.options
.disabled
? '' : null,
622 'data-widget-id': this.options
.id
? 'widget.' + this.options
.id
: null
625 frameEl
.appendChild(E('label', { 'for': id
}));
627 if (this.options
.tooltip
!= null) {
630 if (this.options
.tooltipicon
!= null)
631 icon
= this.options
.tooltipicon
;
634 E('label', { 'class': 'cbi-tooltip-container' },[
636 E('div', { 'class': 'cbi-tooltip' },
643 return this.bind(frameEl
);
647 bind: function(frameEl
) {
650 var input
= frameEl
.querySelector('input[type="checkbox"]');
651 this.setUpdateEvents(input
, 'click', 'blur');
652 this.setChangeEvents(input
, 'change');
654 dom
.bindClassInstance(frameEl
, this);
660 * Test whether the checkbox is currently checked.
663 * @memberof LuCI.ui.Checkbox
665 * Returns `true` when the checkbox is currently checked, otherwise `false`.
667 isChecked: function() {
668 return this.node
.querySelector('input[type="checkbox"]').checked
;
672 getValue: function() {
673 return this.isChecked()
674 ? this.options
.value_enabled
675 : this.options
.value_disabled
;
679 setValue: function(value
) {
680 this.node
.querySelector('input[type="checkbox"]').checked
= (value
== this.options
.value_enabled
);
685 * Instantiate a select dropdown or checkbox/radiobutton group.
687 * @constructor Select
689 * @augments LuCI.ui.AbstractElement
693 * The `Select` class implements either a traditional HTML `<select>` element
694 * or a group of checkboxes or radio buttons, depending on whether multiple
695 * values are enabled or not.
697 * UI widget instances are usually not supposed to be created by view code
698 * directly, instead they're implicitely created by `LuCI.form` when
699 * instantiating CBI forms.
701 * This class is automatically instantiated as part of `LuCI.ui`. To use it
702 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
703 * external JavaScript, use `L.require("ui").then(...)` and access the
704 * `Select` property of the class instance value.
706 * @param {string|string[]} [value=null]
707 * The initial input value(s).
709 * @param {Object<string, string>} choices
710 * Object containing the selectable choices of the widget. The object keys
711 * serve as values for the different choices while the values are used as
714 * @param {LuCI.ui.Select.InitOptions} [options]
715 * Object describing the widget specific options to initialize the inputs.
717 var UISelect
= UIElement
.extend(/** @lends LuCI.ui.Select.prototype */ {
719 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
720 * the following properties are recognized:
722 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
723 * @memberof LuCI.ui.Select
725 * @property {boolean} [multiple=false]
726 * Specifies whether multiple choice values may be selected.
728 * @property {string} [widget=select]
729 * Specifies the kind of widget to render. May be either `select` or
730 * `individual`. When set to `select` an HTML `<select>` element will be
731 * used, otherwise a group of checkbox or radio button elements is created,
732 * depending on the value of the `multiple` option.
734 * @property {string} [orientation=horizontal]
735 * Specifies whether checkbox / radio button groups should be rendered
736 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
739 * @property {boolean|string[]} [sort=false]
740 * Specifies if and how to sort choice values. If set to `true`, the choice
741 * values will be sorted alphabetically. If set to an array of strings, the
742 * choice sort order is derived from the array.
744 * @property {number} [size]
745 * Specifies the HTML `size` attribute to set on the `<select>` element.
746 * Only applicable to the `select` widget type.
748 * @property {string} [placeholder=-- Please choose --]
749 * Specifies a placeholder text which is displayed when no choice is
750 * selected yet. Only applicable to the `select` widget type.
752 __init__: function(value
, choices
, options
) {
753 if (!L
.isObject(choices
))
756 if (!Array
.isArray(value
))
757 value
= (value
!= null && value
!= '') ? [ value
] : [];
759 if (!options
.multiple
&& value
.length
> 1)
763 this.choices
= choices
;
764 this.options
= Object
.assign({
767 orientation
: 'horizontal'
770 if (this.choices
.hasOwnProperty(''))
771 this.options
.optional
= true;
776 var frameEl
= E('div', { 'id': this.options
.id
}),
777 keys
= Object
.keys(this.choices
);
779 if (this.options
.sort
=== true)
781 else if (Array
.isArray(this.options
.sort
))
782 keys
= this.options
.sort
;
784 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
785 frameEl
.appendChild(E('select', {
786 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
787 'name': this.options
.name
,
788 'size': this.options
.size
,
789 'class': 'cbi-input-select',
790 'multiple': this.options
.multiple
? '' : null,
791 'disabled': this.options
.disabled
? '' : null
794 if (this.options
.optional
)
795 frameEl
.lastChild
.appendChild(E('option', {
797 'selected': (this.values
.length
== 0 || this.values
[0] == '') ? '' : null
798 }, [ this.choices
[''] || this.options
.placeholder
|| _('-- Please choose --') ]));
800 for (var i
= 0; i
< keys
.length
; i
++) {
801 if (keys
[i
] == null || keys
[i
] == '')
804 frameEl
.lastChild
.appendChild(E('option', {
806 'selected': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null
807 }, [ this.choices
[keys
[i
]] || keys
[i
] ]));
811 var brEl
= (this.options
.orientation
=== 'horizontal') ? document
.createTextNode(' \xa0 ') : E('br');
813 for (var i
= 0; i
< keys
.length
; i
++) {
814 frameEl
.appendChild(E('span', {
815 'class': 'cbi-%s'.format(this.options
.multiple
? 'checkbox' : 'radio')
818 'id': this.options
.id
? 'widget.%s.%d'.format(this.options
.id
, i
) : null,
819 'name': this.options
.id
|| this.options
.name
,
820 'type': this.options
.multiple
? 'checkbox' : 'radio',
821 'class': this.options
.multiple
? 'cbi-input-checkbox' : 'cbi-input-radio',
823 'checked': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null,
824 'disabled': this.options
.disabled
? '' : null
826 E('label', { 'for': this.options
.id
? 'widget.%s.%d'.format(this.options
.id
, i
) : null }),
828 'click': function(ev
) {
829 ev
.currentTarget
.previousElementSibling
.previousElementSibling
.click();
831 }, [ this.choices
[keys
[i
]] || keys
[i
] ])
834 frameEl
.appendChild(brEl
.cloneNode());
838 return this.bind(frameEl
);
842 bind: function(frameEl
) {
845 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
846 this.setUpdateEvents(frameEl
.firstChild
, 'change', 'click', 'blur');
847 this.setChangeEvents(frameEl
.firstChild
, 'change');
850 var radioEls
= frameEl
.querySelectorAll('input[type="radio"]');
851 for (var i
= 0; i
< radioEls
.length
; i
++) {
852 this.setUpdateEvents(radioEls
[i
], 'change', 'click', 'blur');
853 this.setChangeEvents(radioEls
[i
], 'change', 'click', 'blur');
857 dom
.bindClassInstance(frameEl
, this);
863 getValue: function() {
864 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox')
865 return this.node
.firstChild
.value
;
867 var radioEls
= this.node
.querySelectorAll('input[type="radio"]');
868 for (var i
= 0; i
< radioEls
.length
; i
++)
869 if (radioEls
[i
].checked
)
870 return radioEls
[i
].value
;
876 setValue: function(value
) {
877 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
881 for (var i
= 0; i
< this.node
.firstChild
.options
.length
; i
++)
882 this.node
.firstChild
.options
[i
].selected
= (this.node
.firstChild
.options
[i
].value
== value
);
887 var radioEls
= frameEl
.querySelectorAll('input[type="radio"]');
888 for (var i
= 0; i
< radioEls
.length
; i
++)
889 radioEls
[i
].checked
= (radioEls
[i
].value
== value
);
894 * Instantiate a rich dropdown choice widget.
896 * @constructor Dropdown
898 * @augments LuCI.ui.AbstractElement
902 * The `Dropdown` class implements a rich, stylable dropdown menu which
903 * supports non-text choice labels.
905 * UI widget instances are usually not supposed to be created by view code
906 * directly, instead they're implicitely created by `LuCI.form` when
907 * instantiating CBI forms.
909 * This class is automatically instantiated as part of `LuCI.ui`. To use it
910 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
911 * external JavaScript, use `L.require("ui").then(...)` and access the
912 * `Dropdown` property of the class instance value.
914 * @param {string|string[]} [value=null]
915 * The initial input value(s).
917 * @param {Object<string, *>} choices
918 * Object containing the selectable choices of the widget. The object keys
919 * serve as values for the different choices while the values are used as
922 * @param {LuCI.ui.Dropdown.InitOptions} [options]
923 * Object describing the widget specific options to initialize the dropdown.
925 var UIDropdown
= UIElement
.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
927 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
928 * the following properties are recognized:
930 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
931 * @memberof LuCI.ui.Dropdown
933 * @property {boolean} [optional=true]
934 * Specifies whether the dropdown selection is optional. In contrast to
935 * other widgets, the `optional` constraint of dropdowns works differently;
936 * instead of marking the widget invalid on empty values when set to `false`,
937 * the user is not allowed to deselect all choices.
939 * For single value dropdowns that means that no empty "please select"
940 * choice is offered and for multi value dropdowns, the last selected choice
941 * may not be deselected without selecting another choice first.
943 * @property {boolean} [multiple]
944 * Specifies whether multiple choice values may be selected. It defaults
945 * to `true` when an array is passed as input value to the constructor.
947 * @property {boolean|string[]} [sort=false]
948 * Specifies if and how to sort choice values. If set to `true`, the choice
949 * values will be sorted alphabetically. If set to an array of strings, the
950 * choice sort order is derived from the array.
952 * @property {string} [select_placeholder=-- Please choose --]
953 * Specifies a placeholder text which is displayed when no choice is
956 * @property {string} [custom_placeholder=-- custom --]
957 * Specifies a placeholder text which is displayed in the text input
958 * field allowing to enter custom choice values. Only applicable if the
959 * `create` option is set to `true`.
961 * @property {boolean} [create=false]
962 * Specifies whether custom choices may be entered into the dropdown
965 * @property {string} [create_query=.create-item-input]
966 * Specifies a CSS selector expression used to find the input element
967 * which is used to enter custom choice values. This should not normally
968 * be used except by widgets derived from the Dropdown class.
970 * @property {string} [create_template=script[type="item-template"]]
971 * Specifies a CSS selector expression used to find an HTML element
972 * serving as template for newly added custom choice values.
974 * Any `{{value}}` placeholder string within the template elements text
975 * content will be replaced by the user supplied choice value, the
976 * resulting string is parsed as HTML and appended to the end of the
977 * choice list. The template markup may specify one HTML element with a
978 * `data-label-placeholder` attribute which is replaced by a matching
979 * label value from the `choices` object or with the user supplied value
980 * itself in case `choices` contains no matching choice label.
982 * If the template element is not found or if no `create_template` selector
983 * expression is specified, the default markup for newly created elements is
984 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
986 * @property {string} [create_markup]
987 * This property allows specifying the markup for custom choices directly
988 * instead of referring to a template element through CSS selectors.
990 * Apart from that it works exactly like `create_template`.
992 * @property {number} [display_items=3]
993 * Specifies the maximum amount of choice labels that should be shown in
994 * collapsed dropdown state before further selected choices are cut off.
996 * Only applicable when `multiple` is `true`.
998 * @property {number} [dropdown_items=-1]
999 * Specifies the maximum amount of choices that should be shown when the
1000 * dropdown is open. If the amount of available choices exceeds this number,
1001 * the dropdown area must be scrolled to reach further items.
1003 * If set to `-1`, the dropdown menu will attempt to show all choice values
1004 * and only resort to scrolling if the amount of choices exceeds the available
1005 * screen space above and below the dropdown widget.
1007 * @property {string} [placeholder]
1008 * This property serves as a shortcut to set both `select_placeholder` and
1009 * `custom_placeholder`. Either of these properties will fallback to
1010 * `placeholder` if not specified.
1012 * @property {boolean} [readonly=false]
1013 * Specifies whether the custom choice input field should be rendered
1014 * readonly. Only applicable when `create` is `true`.
1016 * @property {number} [maxlength]
1017 * Specifies the HTML `maxlength` attribute to set on the custom choice
1018 * `<input>` element. Note that this a legacy property that exists for
1019 * compatibility reasons. It is usually better to `maxlength(N)` validation
1020 * expression. Only applicable when `create` is `true`.
1022 __init__: function(value
, choices
, options
) {
1023 if (typeof(choices
) != 'object')
1026 if (!Array
.isArray(value
))
1027 this.values
= (value
!= null && value
!= '') ? [ value
] : [];
1029 this.values
= value
;
1031 this.choices
= choices
;
1032 this.options
= Object
.assign({
1034 multiple
: Array
.isArray(value
),
1036 select_placeholder
: _('-- Please choose --'),
1037 custom_placeholder
: _('-- custom --'),
1041 create_query
: '.create-item-input',
1042 create_template
: 'script[type="item-template"]'
1047 render: function() {
1049 'id': this.options
.id
,
1050 'class': 'cbi-dropdown',
1051 'multiple': this.options
.multiple
? '' : null,
1052 'optional': this.options
.optional
? '' : null,
1053 'disabled': this.options
.disabled
? '' : null
1056 var keys
= Object
.keys(this.choices
);
1058 if (this.options
.sort
=== true)
1060 else if (Array
.isArray(this.options
.sort
))
1061 keys
= this.options
.sort
;
1063 if (this.options
.create
)
1064 for (var i
= 0; i
< this.values
.length
; i
++)
1065 if (!this.choices
.hasOwnProperty(this.values
[i
]))
1066 keys
.push(this.values
[i
]);
1068 for (var i
= 0; i
< keys
.length
; i
++) {
1069 var label
= this.choices
[keys
[i
]];
1071 if (dom
.elem(label
))
1072 label
= label
.cloneNode(true);
1074 sb
.lastElementChild
.appendChild(E('li', {
1075 'data-value': keys
[i
],
1076 'selected': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null
1077 }, [ label
|| keys
[i
] ]));
1080 if (this.options
.create
) {
1081 var createEl
= E('input', {
1083 'class': 'create-item-input',
1084 'readonly': this.options
.readonly
? '' : null,
1085 'maxlength': this.options
.maxlength
,
1086 'placeholder': this.options
.custom_placeholder
|| this.options
.placeholder
1089 if (this.options
.datatype
|| this.options
.validate
)
1090 UI
.prototype.addValidator(createEl
, this.options
.datatype
|| 'string',
1091 true, this.options
.validate
, 'blur', 'keyup');
1093 sb
.lastElementChild
.appendChild(E('li', { 'data-value': '-' }, createEl
));
1096 if (this.options
.create_markup
)
1097 sb
.appendChild(E('script', { type
: 'item-template' },
1098 this.options
.create_markup
));
1100 return this.bind(sb
);
1104 bind: function(sb
) {
1105 var o
= this.options
;
1107 o
.multiple
= sb
.hasAttribute('multiple');
1108 o
.optional
= sb
.hasAttribute('optional');
1109 o
.placeholder
= sb
.getAttribute('placeholder') || o
.placeholder
;
1110 o
.display_items
= parseInt(sb
.getAttribute('display-items') || o
.display_items
);
1111 o
.dropdown_items
= parseInt(sb
.getAttribute('dropdown-items') || o
.dropdown_items
);
1112 o
.create_query
= sb
.getAttribute('item-create') || o
.create_query
;
1113 o
.create_template
= sb
.getAttribute('item-template') || o
.create_template
;
1115 var ul
= sb
.querySelector('ul'),
1116 more
= sb
.appendChild(E('span', { class: 'more', tabindex
: -1 }, '···')),
1117 open
= sb
.appendChild(E('span', { class: 'open', tabindex
: -1 }, '▾')),
1118 canary
= sb
.appendChild(E('div')),
1119 create
= sb
.querySelector(this.options
.create_query
),
1120 ndisplay
= this.options
.display_items
,
1123 if (this.options
.multiple
) {
1124 var items
= ul
.querySelectorAll('li');
1126 for (var i
= 0; i
< items
.length
; i
++) {
1127 this.transformItem(sb
, items
[i
]);
1129 if (items
[i
].hasAttribute('selected') && ndisplay
-- > 0)
1130 items
[i
].setAttribute('display', n
++);
1134 if (this.options
.optional
&& !ul
.querySelector('li[data-value=""]')) {
1135 var placeholder
= E('li', { placeholder
: '' },
1136 this.options
.select_placeholder
|| this.options
.placeholder
);
1139 ? ul
.insertBefore(placeholder
, ul
.firstChild
)
1140 : ul
.appendChild(placeholder
);
1143 var items
= ul
.querySelectorAll('li'),
1144 sel
= sb
.querySelectorAll('[selected]');
1146 sel
.forEach(function(s
) {
1147 s
.removeAttribute('selected');
1150 var s
= sel
[0] || items
[0];
1152 s
.setAttribute('selected', '');
1153 s
.setAttribute('display', n
++);
1159 this.saveValues(sb
, ul
);
1161 ul
.setAttribute('tabindex', -1);
1162 sb
.setAttribute('tabindex', 0);
1165 sb
.setAttribute('more', '')
1167 sb
.removeAttribute('more');
1169 if (ndisplay
== this.options
.display_items
)
1170 sb
.setAttribute('empty', '')
1172 sb
.removeAttribute('empty');
1174 dom
.content(more
, (ndisplay
== this.options
.display_items
)
1175 ? (this.options
.select_placeholder
|| this.options
.placeholder
) : '···');
1178 sb
.addEventListener('click', this.handleClick
.bind(this));
1179 sb
.addEventListener('keydown', this.handleKeydown
.bind(this));
1180 sb
.addEventListener('cbi-dropdown-close', this.handleDropdownClose
.bind(this));
1181 sb
.addEventListener('cbi-dropdown-select', this.handleDropdownSelect
.bind(this));
1183 if ('ontouchstart' in window
) {
1184 sb
.addEventListener('touchstart', function(ev
) { ev
.stopPropagation(); });
1185 window
.addEventListener('touchstart', this.closeAllDropdowns
);
1188 sb
.addEventListener('mouseover', this.handleMouseover
.bind(this));
1189 sb
.addEventListener('focus', this.handleFocus
.bind(this));
1191 canary
.addEventListener('focus', this.handleCanaryFocus
.bind(this));
1193 window
.addEventListener('mouseover', this.setFocus
);
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 l
.setAttribute('tabindex', 0);
1344 sb
.lastElementChild
.setAttribute('tabindex', 0);
1346 this.setFocus(sb
, sel
|| li
[0], true);
1350 closeDropdown: function(sb
, no_focus
) {
1351 if (!sb
.hasAttribute('open'))
1354 var pv
= sb
.querySelector('ul.preview'),
1355 ul
= sb
.querySelector('ul.dropdown'),
1356 li
= ul
.querySelectorAll('li'),
1357 fl
= findParent(sb
, '.cbi-value-field');
1359 li
.forEach(function(l
) { l
.removeAttribute('tabindex'); });
1360 sb
.lastElementChild
.removeAttribute('tabindex');
1363 sb
.removeAttribute('open');
1364 sb
.style
.width
= sb
.style
.height
= '';
1366 ul
.classList
.remove('dropdown');
1367 ul
.style
.top
= ul
.style
.bottom
= ul
.style
.maxHeight
= '';
1370 fl
.classList
.remove('cbi-dropdown-open');
1373 this.setFocus(sb
, sb
);
1375 this.saveValues(sb
, ul
);
1379 toggleItem: function(sb
, li
, force_state
) {
1380 var ul
= li
.parentNode
;
1382 if (li
.hasAttribute('unselectable'))
1385 if (this.options
.multiple
) {
1386 var cbox
= li
.querySelector('input[type="checkbox"]'),
1387 items
= li
.parentNode
.querySelectorAll('li'),
1388 label
= sb
.querySelector('ul.preview'),
1389 sel
= li
.parentNode
.querySelectorAll('[selected]').length
,
1390 more
= sb
.querySelector('.more'),
1391 ndisplay
= this.options
.display_items
,
1394 if (li
.hasAttribute('selected')) {
1395 if (force_state
!== true) {
1396 if (sel
> 1 || this.options
.optional
) {
1397 li
.removeAttribute('selected');
1398 cbox
.checked
= cbox
.disabled
= false;
1402 cbox
.disabled
= true;
1407 if (force_state
!== false) {
1408 li
.setAttribute('selected', '');
1409 cbox
.checked
= true;
1410 cbox
.disabled
= false;
1415 while (label
&& label
.firstElementChild
)
1416 label
.removeChild(label
.firstElementChild
);
1418 for (var i
= 0; i
< items
.length
; i
++) {
1419 items
[i
].removeAttribute('display');
1420 if (items
[i
].hasAttribute('selected')) {
1421 if (ndisplay
-- > 0) {
1422 items
[i
].setAttribute('display', n
++);
1424 label
.appendChild(items
[i
].cloneNode(true));
1426 var c
= items
[i
].querySelector('input[type="checkbox"]');
1428 c
.disabled
= (sel
== 1 && !this.options
.optional
);
1433 sb
.setAttribute('more', '');
1435 sb
.removeAttribute('more');
1437 if (ndisplay
=== this.options
.display_items
)
1438 sb
.setAttribute('empty', '');
1440 sb
.removeAttribute('empty');
1442 dom
.content(more
, (ndisplay
=== this.options
.display_items
)
1443 ? (this.options
.select_placeholder
|| this.options
.placeholder
) : '···');
1446 var sel
= li
.parentNode
.querySelector('[selected]');
1448 sel
.removeAttribute('display');
1449 sel
.removeAttribute('selected');
1452 li
.setAttribute('display', 0);
1453 li
.setAttribute('selected', '');
1455 this.closeDropdown(sb
, true);
1458 this.saveValues(sb
, ul
);
1462 transformItem: function(sb
, li
) {
1463 var cbox
= E('form', {}, E('input', { type
: 'checkbox', tabindex
: -1, onclick
: 'event.preventDefault()' })),
1466 while (li
.firstChild
)
1467 label
.appendChild(li
.firstChild
);
1469 li
.appendChild(cbox
);
1470 li
.appendChild(label
);
1474 saveValues: function(sb
, ul
) {
1475 var sel
= ul
.querySelectorAll('li[selected]'),
1476 div
= sb
.lastElementChild
,
1477 name
= this.options
.name
,
1481 while (div
.lastElementChild
)
1482 div
.removeChild(div
.lastElementChild
);
1484 sel
.forEach(function (s
) {
1485 if (s
.hasAttribute('placeholder'))
1490 value
: s
.hasAttribute('data-value') ? s
.getAttribute('data-value') : s
.innerText
,
1494 div
.appendChild(E('input', {
1502 strval
+= strval
.length
? ' ' + v
.value
: v
.value
;
1510 if (this.options
.multiple
)
1511 detail
.values
= values
;
1513 detail
.value
= values
.length
? values
[0] : null;
1517 sb
.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1524 setValues: function(sb
, values
) {
1525 var ul
= sb
.querySelector('ul');
1527 if (this.options
.create
) {
1528 for (var value
in values
) {
1529 this.createItems(sb
, value
);
1531 if (!this.options
.multiple
)
1536 if (this.options
.multiple
) {
1537 var lis
= ul
.querySelectorAll('li[data-value]');
1538 for (var i
= 0; i
< lis
.length
; i
++) {
1539 var value
= lis
[i
].getAttribute('data-value');
1540 if (values
=== null || !(value
in values
))
1541 this.toggleItem(sb
, lis
[i
], false);
1543 this.toggleItem(sb
, lis
[i
], true);
1547 var ph
= ul
.querySelector('li[placeholder]');
1549 this.toggleItem(sb
, ph
);
1551 var lis
= ul
.querySelectorAll('li[data-value]');
1552 for (var i
= 0; i
< lis
.length
; i
++) {
1553 var value
= lis
[i
].getAttribute('data-value');
1554 if (values
!== null && (value
in values
))
1555 this.toggleItem(sb
, lis
[i
]);
1561 setFocus: function(sb
, elem
, scroll
) {
1562 if (sb
&& sb
.hasAttribute
&& sb
.hasAttribute('locked-in'))
1565 if (sb
.target
&& findParent(sb
.target
, 'ul.dropdown'))
1568 document
.querySelectorAll('.focus').forEach(function(e
) {
1569 if (!matchesElem(e
, 'input')) {
1570 e
.classList
.remove('focus');
1577 elem
.classList
.add('focus');
1580 elem
.parentNode
.scrollTop
= elem
.offsetTop
- elem
.parentNode
.offsetTop
;
1585 createChoiceElement: function(sb
, value
, label
) {
1586 var tpl
= sb
.querySelector(this.options
.create_template
),
1590 markup
= (tpl
.textContent
|| tpl
.innerHTML
|| tpl
.firstChild
.data
).replace(/^<!--|-->$/, '').trim();
1592 markup
= '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1594 var new_item
= E(markup
.replace(/{{value}}/g, '%h'.format(value
))),
1595 placeholder
= new_item
.querySelector('[data-label-placeholder]');
1598 var content
= E('span', {}, label
|| this.choices
[value
] || [ value
]);
1600 while (content
.firstChild
)
1601 placeholder
.parentNode
.insertBefore(content
.firstChild
, placeholder
);
1603 placeholder
.parentNode
.removeChild(placeholder
);
1606 if (this.options
.multiple
)
1607 this.transformItem(sb
, new_item
);
1613 createItems: function(sb
, value
) {
1615 val
= (value
|| '').trim(),
1616 ul
= sb
.querySelector('ul');
1618 if (!sbox
.options
.multiple
)
1619 val
= val
.length
? [ val
] : [];
1621 val
= val
.length
? val
.split(/\s+/) : [];
1623 val
.forEach(function(item
) {
1624 var new_item
= null;
1626 ul
.childNodes
.forEach(function(li
) {
1627 if (li
.getAttribute
&& li
.getAttribute('data-value') === item
)
1632 new_item
= sbox
.createChoiceElement(sb
, item
);
1634 if (!sbox
.options
.multiple
) {
1635 var old
= ul
.querySelector('li[created]');
1637 ul
.removeChild(old
);
1639 new_item
.setAttribute('created', '');
1642 new_item
= ul
.insertBefore(new_item
, ul
.lastElementChild
);
1645 sbox
.toggleItem(sb
, new_item
, true);
1646 sbox
.setFocus(sb
, new_item
, true);
1651 * Remove all existing choices from the dropdown menu.
1653 * This function removes all preexisting dropdown choices from the widget,
1654 * keeping only choices currently being selected unless `reset_values` is
1655 * given, in which case all choices and deselected and removed.
1658 * @memberof LuCI.ui.Dropdown
1659 * @param {boolean} [reset_value=false]
1660 * If set to `true`, deselect and remove selected choices as well instead
1663 clearChoices: function(reset_value
) {
1664 var ul
= this.node
.querySelector('ul'),
1665 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [],
1666 len
= lis
.length
- (this.options
.create
? 1 : 0),
1667 val
= reset_value
? null : this.getValue();
1669 for (var i
= 0; i
< len
; i
++) {
1670 var lival
= lis
[i
].getAttribute('data-value');
1672 (!this.options
.multiple
&& val
!= lival
) ||
1673 (this.options
.multiple
&& val
.indexOf(lival
) == -1))
1674 ul
.removeChild(lis
[i
]);
1678 this.setValues(this.node
, {});
1682 * Add new choices to the dropdown menu.
1684 * This function adds further choices to an existing dropdown menu,
1685 * ignoring choice values which are already present.
1688 * @memberof LuCI.ui.Dropdown
1689 * @param {string[]} values
1690 * The choice values to add to the dropdown widget.
1692 * @param {Object<string, *>} labels
1693 * The choice label values to use when adding dropdown choices. If no
1694 * label is found for a particular choice value, the value itself is used
1695 * as label text. Choice labels may be any valid value accepted by
1696 * {@link LuCI.dom#content}.
1698 addChoices: function(values
, labels
) {
1700 ul
= sb
.querySelector('ul'),
1701 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [];
1703 if (!Array
.isArray(values
))
1704 values
= L
.toArray(values
);
1706 if (!L
.isObject(labels
))
1709 for (var i
= 0; i
< values
.length
; i
++) {
1712 for (var j
= 0; j
< lis
.length
; j
++) {
1713 if (lis
[j
].getAttribute('data-value') === values
[i
]) {
1723 this.createChoiceElement(sb
, values
[i
], labels
[values
[i
]]),
1724 ul
.lastElementChild
);
1729 * Close all open dropdown widgets in the current document.
1731 closeAllDropdowns: function() {
1732 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1733 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1738 handleClick: function(ev
) {
1739 var sb
= ev
.currentTarget
;
1741 if (!sb
.hasAttribute('open')) {
1742 if (!matchesElem(ev
.target
, 'input'))
1743 this.openDropdown(sb
);
1746 var li
= findParent(ev
.target
, 'li');
1747 if (li
&& li
.parentNode
.classList
.contains('dropdown'))
1748 this.toggleItem(sb
, li
);
1749 else if (li
&& li
.parentNode
.classList
.contains('preview'))
1750 this.closeDropdown(sb
);
1751 else if (matchesElem(ev
.target
, 'span.open, span.more'))
1752 this.closeDropdown(sb
);
1755 ev
.preventDefault();
1756 ev
.stopPropagation();
1760 handleKeydown: function(ev
) {
1761 var sb
= ev
.currentTarget
;
1763 if (matchesElem(ev
.target
, 'input'))
1766 if (!sb
.hasAttribute('open')) {
1767 switch (ev
.keyCode
) {
1772 this.openDropdown(sb
);
1773 ev
.preventDefault();
1777 var active
= findParent(document
.activeElement
, 'li');
1779 switch (ev
.keyCode
) {
1781 this.closeDropdown(sb
);
1786 if (!active
.hasAttribute('selected'))
1787 this.toggleItem(sb
, active
);
1788 this.closeDropdown(sb
);
1789 ev
.preventDefault();
1795 this.toggleItem(sb
, active
);
1796 ev
.preventDefault();
1801 if (active
&& active
.previousElementSibling
) {
1802 this.setFocus(sb
, active
.previousElementSibling
);
1803 ev
.preventDefault();
1808 if (active
&& active
.nextElementSibling
) {
1809 this.setFocus(sb
, active
.nextElementSibling
);
1810 ev
.preventDefault();
1818 handleDropdownClose: function(ev
) {
1819 var sb
= ev
.currentTarget
;
1821 this.closeDropdown(sb
, true);
1825 handleDropdownSelect: function(ev
) {
1826 var sb
= ev
.currentTarget
,
1827 li
= findParent(ev
.target
, 'li');
1832 this.toggleItem(sb
, li
);
1833 this.closeDropdown(sb
, true);
1837 handleMouseover: function(ev
) {
1838 var sb
= ev
.currentTarget
;
1840 if (!sb
.hasAttribute('open'))
1843 var li
= findParent(ev
.target
, 'li');
1845 if (li
&& li
.parentNode
.classList
.contains('dropdown'))
1846 this.setFocus(sb
, li
);
1850 handleFocus: function(ev
) {
1851 var sb
= ev
.currentTarget
;
1853 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1854 if (s
!== sb
|| sb
.hasAttribute('open'))
1855 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1860 handleCanaryFocus: function(ev
) {
1861 this.closeDropdown(ev
.currentTarget
.parentNode
);
1865 handleCreateKeydown: function(ev
) {
1866 var input
= ev
.currentTarget
,
1867 sb
= findParent(input
, '.cbi-dropdown');
1869 switch (ev
.keyCode
) {
1871 ev
.preventDefault();
1873 if (input
.classList
.contains('cbi-input-invalid'))
1876 this.createItems(sb
, input
.value
);
1884 handleCreateFocus: function(ev
) {
1885 var input
= ev
.currentTarget
,
1886 cbox
= findParent(input
, 'li').querySelector('input[type="checkbox"]'),
1887 sb
= findParent(input
, '.cbi-dropdown');
1890 cbox
.checked
= true;
1892 sb
.setAttribute('locked-in', '');
1896 handleCreateBlur: function(ev
) {
1897 var input
= ev
.currentTarget
,
1898 cbox
= findParent(input
, 'li').querySelector('input[type="checkbox"]'),
1899 sb
= findParent(input
, '.cbi-dropdown');
1902 cbox
.checked
= false;
1904 sb
.removeAttribute('locked-in');
1908 handleCreateClick: function(ev
) {
1909 ev
.currentTarget
.querySelector(this.options
.create_query
).focus();
1913 setValue: function(values
) {
1914 if (this.options
.multiple
) {
1915 if (!Array
.isArray(values
))
1916 values
= (values
!= null && values
!= '') ? [ values
] : [];
1920 for (var i
= 0; i
< values
.length
; i
++)
1921 v
[values
[i
]] = true;
1923 this.setValues(this.node
, v
);
1928 if (values
!= null) {
1929 if (Array
.isArray(values
))
1930 v
[values
[0]] = true;
1935 this.setValues(this.node
, v
);
1940 getValue: function() {
1941 var div
= this.node
.lastElementChild
,
1942 h
= div
.querySelectorAll('input[type="hidden"]'),
1945 for (var i
= 0; i
< h
.length
; i
++)
1948 return this.options
.multiple
? v
: v
[0];
1953 * Instantiate a rich dropdown choice widget allowing custom values.
1955 * @constructor Combobox
1957 * @augments LuCI.ui.Dropdown
1961 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1962 * to enter custom values. Historically, comboboxes used to be a dedicated
1963 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1964 * with a set of enforced default properties for easier instantiation.
1966 * UI widget instances are usually not supposed to be created by view code
1967 * directly, instead they're implicitely created by `LuCI.form` when
1968 * instantiating CBI forms.
1970 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1971 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1972 * external JavaScript, use `L.require("ui").then(...)` and access the
1973 * `Combobox` property of the class instance value.
1975 * @param {string|string[]} [value=null]
1976 * The initial input value(s).
1978 * @param {Object<string, *>} choices
1979 * Object containing the selectable choices of the widget. The object keys
1980 * serve as values for the different choices while the values are used as
1983 * @param {LuCI.ui.Combobox.InitOptions} [options]
1984 * Object describing the widget specific options to initialize the dropdown.
1986 var UICombobox
= UIDropdown
.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1988 * Comboboxes support the same properties as
1989 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1990 * specific values for the following properties:
1992 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1993 * @memberof LuCI.ui.Combobox
1995 * @property {boolean} multiple=false
1996 * Since Comboboxes never allow selecting multiple values, this property
1997 * is forcibly set to `false`.
1999 * @property {boolean} create=true
2000 * Since Comboboxes always allow custom choice values, this property is
2001 * forcibly set to `true`.
2003 * @property {boolean} optional=true
2004 * Since Comboboxes are always optional, this property is forcibly set to
2007 __init__: function(value
, choices
, options
) {
2008 this.super('__init__', [ value
, choices
, Object
.assign({
2009 select_placeholder
: _('-- Please choose --'),
2010 custom_placeholder
: _('-- custom --'),
2022 * Instantiate a combo button widget offering multiple action choices.
2024 * @constructor ComboButton
2026 * @augments LuCI.ui.Dropdown
2030 * The `ComboButton` class implements a button element which can be expanded
2031 * into a dropdown to chose from a set of different action choices.
2033 * UI widget instances are usually not supposed to be created by view code
2034 * directly, instead they're implicitely created by `LuCI.form` when
2035 * instantiating CBI forms.
2037 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2038 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
2039 * external JavaScript, use `L.require("ui").then(...)` and access the
2040 * `ComboButton` property of the class instance value.
2042 * @param {string|string[]} [value=null]
2043 * The initial input value(s).
2045 * @param {Object<string, *>} choices
2046 * Object containing the selectable choices of the widget. The object keys
2047 * serve as values for the different choices while the values are used as
2050 * @param {LuCI.ui.ComboButton.InitOptions} [options]
2051 * Object describing the widget specific options to initialize the button.
2053 var UIComboButton
= UIDropdown
.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
2055 * ComboButtons support the same properties as
2056 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2057 * specific values for some properties and add aditional button specific
2060 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2061 * @memberof LuCI.ui.ComboButton
2063 * @property {boolean} multiple=false
2064 * Since ComboButtons never allow selecting multiple actions, this property
2065 * is forcibly set to `false`.
2067 * @property {boolean} create=false
2068 * Since ComboButtons never allow creating custom choices, this property
2069 * is forcibly set to `false`.
2071 * @property {boolean} optional=false
2072 * Since ComboButtons must always select one action, this property is
2073 * forcibly set to `false`.
2075 * @property {Object<string, string>} [classes]
2076 * Specifies a mapping of choice values to CSS class names. If an action
2077 * choice is selected by the user and if a corresponding entry exists in
2078 * the `classes` object, the class names corresponding to the selected
2079 * value are set on the button element.
2081 * This is useful to apply different button styles, such as colors, to the
2082 * combined button depending on the selected action.
2084 * @property {function} [click]
2085 * Specifies a handler function to invoke when the user clicks the button.
2086 * This function will be called with the button DOM node as `this` context
2087 * and receive the DOM click event as first as well as the selected action
2088 * choice value as second argument.
2090 __init__: function(value
, choices
, options
) {
2091 this.super('__init__', [ value
, choices
, Object
.assign({
2101 render: function(/* ... */) {
2102 var node
= UIDropdown
.prototype.render
.apply(this, arguments
),
2103 val
= this.getValue();
2105 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2106 node
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2112 handleClick: function(ev
) {
2113 var sb
= ev
.currentTarget
,
2116 if (sb
.hasAttribute('open') || dom
.matches(t
, '.cbi-dropdown > span.open'))
2117 return UIDropdown
.prototype.handleClick
.apply(this, arguments
);
2119 if (this.options
.click
)
2120 return this.options
.click
.call(sb
, ev
, this.getValue());
2124 toggleItem: function(sb
/*, ... */) {
2125 var rv
= UIDropdown
.prototype.toggleItem
.apply(this, arguments
),
2126 val
= this.getValue();
2128 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2129 sb
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2131 sb
.setAttribute('class', 'cbi-dropdown');
2138 * Instantiate a dynamic list widget.
2140 * @constructor DynamicList
2142 * @augments LuCI.ui.AbstractElement
2146 * The `DynamicList` class implements a widget which allows the user to specify
2147 * an arbitrary amount of input values, either from free formed text input or
2148 * from a set of predefined choices.
2150 * UI widget instances are usually not supposed to be created by view code
2151 * directly, instead they're implicitely created by `LuCI.form` when
2152 * instantiating CBI forms.
2154 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2155 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2156 * external JavaScript, use `L.require("ui").then(...)` and access the
2157 * `DynamicList` property of the class instance value.
2159 * @param {string|string[]} [value=null]
2160 * The initial input value(s).
2162 * @param {Object<string, *>} [choices]
2163 * Object containing the selectable choices of the widget. The object keys
2164 * serve as values for the different choices while the values are used as
2165 * choice labels. If omitted, no default choices are presented to the user,
2166 * instead a plain text input field is rendered allowing the user to add
2167 * arbitrary values to the dynamic list.
2169 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2170 * Object describing the widget specific options to initialize the dynamic list.
2172 var UIDynamicList
= UIElement
.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2174 * In case choices are passed to the dynamic list contructor, the widget
2175 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2176 * but enforces specific values for some dropdown properties.
2178 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2179 * @memberof LuCI.ui.DynamicList
2181 * @property {boolean} multiple=false
2182 * Since dynamic lists never allow selecting multiple choices when adding
2183 * another list item, this property is forcibly set to `false`.
2185 * @property {boolean} optional=true
2186 * Since dynamic lists use an embedded dropdown to present a list of
2187 * predefined choice values, the dropdown must be made optional to allow
2188 * it to remain unselected.
2190 __init__: function(values
, choices
, options
) {
2191 if (!Array
.isArray(values
))
2192 values
= (values
!= null && values
!= '') ? [ values
] : [];
2194 if (typeof(choices
) != 'object')
2197 this.values
= values
;
2198 this.choices
= choices
;
2199 this.options
= Object
.assign({}, options
, {
2206 render: function() {
2208 'id': this.options
.id
,
2209 'class': 'cbi-dynlist',
2210 'disabled': this.options
.disabled
? '' : null
2211 }, E('div', { 'class': 'add-item' }));
2214 if (this.options
.placeholder
!= null)
2215 this.options
.select_placeholder
= this.options
.placeholder
;
2217 var cbox
= new UICombobox(null, this.choices
, this.options
);
2219 dl
.lastElementChild
.appendChild(cbox
.render());
2222 var inputEl
= E('input', {
2223 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
2225 'class': 'cbi-input-text',
2226 'placeholder': this.options
.placeholder
,
2227 'disabled': this.options
.disabled
? '' : null
2230 dl
.lastElementChild
.appendChild(inputEl
);
2231 dl
.lastElementChild
.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2233 if (this.options
.datatype
|| this.options
.validate
)
2234 UI
.prototype.addValidator(inputEl
, this.options
.datatype
|| 'string',
2235 true, this.options
.validate
, 'blur', 'keyup');
2238 for (var i
= 0; i
< this.values
.length
; i
++) {
2239 var label
= this.choices
? this.choices
[this.values
[i
]] : null;
2241 if (dom
.elem(label
))
2242 label
= label
.cloneNode(true);
2244 this.addItem(dl
, this.values
[i
], label
);
2247 return this.bind(dl
);
2251 bind: function(dl
) {
2252 dl
.addEventListener('click', L
.bind(this.handleClick
, this));
2253 dl
.addEventListener('keydown', L
.bind(this.handleKeydown
, this));
2254 dl
.addEventListener('cbi-dropdown-change', L
.bind(this.handleDropdownChange
, this));
2258 this.setUpdateEvents(dl
, 'cbi-dynlist-change');
2259 this.setChangeEvents(dl
, 'cbi-dynlist-change');
2261 dom
.bindClassInstance(dl
, this);
2267 addItem: function(dl
, value
, text
, flash
) {
2269 new_item
= E('div', { 'class': flash
? 'item flash' : 'item', 'tabindex': 0 }, [
2270 E('span', {}, [ text
|| value
]),
2273 'name': this.options
.name
,
2274 'value': value
})]);
2276 dl
.querySelectorAll('.item').forEach(function(item
) {
2280 var hidden
= item
.querySelector('input[type="hidden"]');
2282 if (hidden
&& hidden
.parentNode
!== item
)
2285 if (hidden
&& hidden
.value
=== value
)
2290 var ai
= dl
.querySelector('.add-item');
2291 ai
.parentNode
.insertBefore(new_item
, ai
);
2294 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2306 removeItem: function(dl
, item
) {
2307 var value
= item
.querySelector('input[type="hidden"]').value
;
2308 var sb
= dl
.querySelector('.cbi-dropdown');
2310 sb
.querySelectorAll('ul > li').forEach(function(li
) {
2311 if (li
.getAttribute('data-value') === value
) {
2312 if (li
.hasAttribute('dynlistcustom'))
2313 li
.parentNode
.removeChild(li
);
2315 li
.removeAttribute('unselectable');
2319 item
.parentNode
.removeChild(item
);
2321 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2333 handleClick: function(ev
) {
2334 var dl
= ev
.currentTarget
,
2335 item
= findParent(ev
.target
, '.item');
2337 if (this.options
.disabled
)
2341 this.removeItem(dl
, item
);
2343 else if (matchesElem(ev
.target
, '.cbi-button-add')) {
2344 var input
= ev
.target
.previousElementSibling
;
2345 if (input
.value
.length
&& !input
.classList
.contains('cbi-input-invalid')) {
2346 this.addItem(dl
, input
.value
, null, true);
2353 handleDropdownChange: function(ev
) {
2354 var dl
= ev
.currentTarget
,
2355 sbIn
= ev
.detail
.instance
,
2356 sbEl
= ev
.detail
.element
,
2357 sbVal
= ev
.detail
.value
;
2362 sbIn
.setValues(sbEl
, null);
2363 sbVal
.element
.setAttribute('unselectable', '');
2365 if (sbVal
.element
.hasAttribute('created')) {
2366 sbVal
.element
.removeAttribute('created');
2367 sbVal
.element
.setAttribute('dynlistcustom', '');
2370 var label
= sbVal
.text
;
2372 if (sbVal
.element
) {
2375 for (var i
= 0; i
< sbVal
.element
.childNodes
.length
; i
++)
2376 label
.appendChild(sbVal
.element
.childNodes
[i
].cloneNode(true));
2379 this.addItem(dl
, sbVal
.value
, label
, true);
2383 handleKeydown: function(ev
) {
2384 var dl
= ev
.currentTarget
,
2385 item
= findParent(ev
.target
, '.item');
2388 switch (ev
.keyCode
) {
2389 case 8: /* backspace */
2390 if (item
.previousElementSibling
)
2391 item
.previousElementSibling
.focus();
2393 this.removeItem(dl
, item
);
2396 case 46: /* delete */
2397 if (item
.nextElementSibling
) {
2398 if (item
.nextElementSibling
.classList
.contains('item'))
2399 item
.nextElementSibling
.focus();
2401 item
.nextElementSibling
.firstElementChild
.focus();
2404 this.removeItem(dl
, item
);
2408 else if (matchesElem(ev
.target
, '.cbi-input-text')) {
2409 switch (ev
.keyCode
) {
2410 case 13: /* enter */
2411 if (ev
.target
.value
.length
&& !ev
.target
.classList
.contains('cbi-input-invalid')) {
2412 this.addItem(dl
, ev
.target
.value
, null, true);
2413 ev
.target
.value
= '';
2418 ev
.preventDefault();
2425 getValue: function() {
2426 var items
= this.node
.querySelectorAll('.item > input[type="hidden"]'),
2427 input
= this.node
.querySelector('.add-item > input[type="text"]'),
2430 for (var i
= 0; i
< items
.length
; i
++)
2431 v
.push(items
[i
].value
);
2433 if (input
&& input
.value
!= null && input
.value
.match(/\S/) &&
2434 input
.classList
.contains('cbi-input-invalid') == false &&
2435 v
.filter(function(s
) { return s
== input
.value
}).length
== 0)
2436 v
.push(input
.value
);
2442 setValue: function(values
) {
2443 if (!Array
.isArray(values
))
2444 values
= (values
!= null && values
!= '') ? [ values
] : [];
2446 var items
= this.node
.querySelectorAll('.item');
2448 for (var i
= 0; i
< items
.length
; i
++)
2449 if (items
[i
].parentNode
=== this.node
)
2450 this.removeItem(this.node
, items
[i
]);
2452 for (var i
= 0; i
< values
.length
; i
++)
2453 this.addItem(this.node
, values
[i
],
2454 this.choices
? this.choices
[values
[i
]] : null);
2458 * Add new suggested choices to the dynamic list.
2460 * This function adds further choices to an existing dynamic list,
2461 * ignoring choice values which are already present.
2464 * @memberof LuCI.ui.DynamicList
2465 * @param {string[]} values
2466 * The choice values to add to the dynamic lists suggestion dropdown.
2468 * @param {Object<string, *>} labels
2469 * The choice label values to use when adding suggested choices. If no
2470 * label is found for a particular choice value, the value itself is used
2471 * as label text. Choice labels may be any valid value accepted by
2472 * {@link LuCI.dom#content}.
2474 addChoices: function(values
, labels
) {
2475 var dl
= this.node
.lastElementChild
.firstElementChild
;
2476 dom
.callClassMethod(dl
, 'addChoices', values
, labels
);
2480 * Remove all existing choices from the dynamic list.
2482 * This function removes all preexisting suggested choices from the widget.
2485 * @memberof LuCI.ui.DynamicList
2487 clearChoices: function() {
2488 var dl
= this.node
.lastElementChild
.firstElementChild
;
2489 dom
.callClassMethod(dl
, 'clearChoices');
2494 * Instantiate a hidden input field widget.
2496 * @constructor Hiddenfield
2498 * @augments LuCI.ui.AbstractElement
2502 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2503 * which allows to store form data without exposing it to the user.
2505 * UI widget instances are usually not supposed to be created by view code
2506 * directly, instead they're implicitely created by `LuCI.form` when
2507 * instantiating CBI forms.
2509 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2510 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2511 * external JavaScript, use `L.require("ui").then(...)` and access the
2512 * `Hiddenfield` property of the class instance value.
2514 * @param {string|string[]} [value=null]
2515 * The initial input value.
2517 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2518 * Object describing the widget specific options to initialize the hidden input.
2520 var UIHiddenfield
= UIElement
.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2521 __init__: function(value
, options
) {
2523 this.options
= Object
.assign({
2529 render: function() {
2530 var hiddenEl
= E('input', {
2531 'id': this.options
.id
,
2536 return this.bind(hiddenEl
);
2540 bind: function(hiddenEl
) {
2541 this.node
= hiddenEl
;
2543 dom
.bindClassInstance(hiddenEl
, this);
2549 getValue: function() {
2550 return this.node
.value
;
2554 setValue: function(value
) {
2555 this.node
.value
= value
;
2560 * Instantiate a file upload widget.
2562 * @constructor FileUpload
2564 * @augments LuCI.ui.AbstractElement
2568 * The `FileUpload` class implements a widget which allows the user to upload,
2569 * browse, select and delete files beneath a predefined remote directory.
2571 * UI widget instances are usually not supposed to be created by view code
2572 * directly, instead they're implicitely created by `LuCI.form` when
2573 * instantiating CBI forms.
2575 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2576 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2577 * external JavaScript, use `L.require("ui").then(...)` and access the
2578 * `FileUpload` property of the class instance value.
2580 * @param {string|string[]} [value=null]
2581 * The initial input value.
2583 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2584 * Object describing the widget specific options to initialize the file
2587 var UIFileUpload
= UIElement
.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2589 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2590 * the following properties are recognized:
2592 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2593 * @memberof LuCI.ui.FileUpload
2595 * @property {boolean} [show_hidden=false]
2596 * Specifies whether hidden files should be displayed when browsing remote
2597 * files. Note that this is not a security feature, hidden files are always
2598 * present in the remote file listings received, this option merely controls
2599 * whether they're displayed or not.
2601 * @property {boolean} [enable_upload=true]
2602 * Specifies whether the widget allows the user to upload files. If set to
2603 * `false`, only existing files may be selected. Note that this is not a
2604 * security feature. Whether file upload requests are accepted remotely
2605 * depends on the ACL setup for the current session. This option merely
2606 * controls whether the upload controls are rendered or not.
2608 * @property {boolean} [enable_remove=true]
2609 * Specifies whether the widget allows the user to delete remove files.
2610 * If set to `false`, existing files may not be removed. Note that this is
2611 * not a security feature. Whether file delete requests are accepted
2612 * remotely depends on the ACL setup for the current session. This option
2613 * merely controls whether the file remove controls are rendered or not.
2615 * @property {string} [root_directory=/etc/luci-uploads]
2616 * Specifies the remote directory the upload and file browsing actions take
2617 * place in. Browsing to directories outside of the root directory is
2618 * prevented by the widget. Note that this is not a security feature.
2619 * Whether remote directories are browseable or not solely depends on the
2620 * ACL setup for the current session.
2622 __init__: function(value
, options
) {
2624 this.options
= Object
.assign({
2626 enable_upload
: true,
2627 enable_remove
: true,
2628 root_directory
: '/etc/luci-uploads'
2633 bind: function(browserEl
) {
2634 this.node
= browserEl
;
2636 this.setUpdateEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2637 this.setChangeEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2639 dom
.bindClassInstance(browserEl
, this);
2645 render: function() {
2646 return L
.resolveDefault(this.value
!= null ? fs
.stat(this.value
) : null).then(L
.bind(function(stat
) {
2649 if (L
.isObject(stat
) && stat
.type
!= 'directory')
2652 if (this.stat
!= null)
2653 label
= [ this.iconForType(this.stat
.type
), ' %s (%1000mB)'.format(this.truncatePath(this.stat
.path
), this.stat
.size
) ];
2654 else if (this.value
!= null)
2655 label
= [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value
), _('File not accessible')) ];
2657 label
= [ _('Select file…') ];
2659 return this.bind(E('div', { 'id': this.options
.id
}, [
2662 'click': UI
.prototype.createHandlerFn(this, 'handleFileBrowser'),
2663 'disabled': this.options
.disabled
? '' : null
2666 'class': 'cbi-filebrowser'
2670 'name': this.options
.name
,
2678 truncatePath: function(path
) {
2679 if (path
.length
> 50)
2680 path
= path
.substring(0, 25) + '…' + path
.substring(path
.length
- 25);
2686 iconForType: function(type
) {
2690 'src': L
.resource('cbi/link.svg'),
2692 'title': _('Symbolic link'),
2698 'src': L
.resource('cbi/folder.svg'),
2700 'title': _('Directory'),
2706 'src': L
.resource('cbi/file.svg'),
2715 canonicalizePath: function(path
) {
2716 return path
.replace(/\/{2,}/, '/')
2717 .replace(/\/\.(\/|$)/g, '/')
2718 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2719 .replace(/\/$/, '');
2723 splitPath: function(path
) {
2724 var croot
= this.canonicalizePath(this.options
.root_directory
|| '/'),
2725 cpath
= this.canonicalizePath(path
|| '/');
2727 if (cpath
.length
<= croot
.length
)
2730 if (cpath
.charAt(croot
.length
) != '/')
2733 var parts
= cpath
.substring(croot
.length
+ 1).split(/\//);
2735 parts
.unshift(croot
);
2741 handleUpload: function(path
, list
, ev
) {
2742 var form
= ev
.target
.parentNode
,
2743 fileinput
= form
.querySelector('input[type="file"]'),
2744 nameinput
= form
.querySelector('input[type="text"]'),
2745 filename
= (nameinput
.value
!= null ? nameinput
.value
: '').trim();
2747 ev
.preventDefault();
2749 if (filename
== '' || filename
.match(/\//) || fileinput
.files
[0] == null)
2752 var existing
= list
.filter(function(e
) { return e
.name
== filename
})[0];
2754 if (existing
!= null && existing
.type
== 'directory')
2755 return alert(_('A directory with the same name already exists.'));
2756 else if (existing
!= null && !confirm(_('Overwrite existing file "%s" ?').format(filename
)))
2759 var data
= new FormData();
2761 data
.append('sessionid', L
.env
.sessionid
);
2762 data
.append('filename', path
+ '/' + filename
);
2763 data
.append('filedata', fileinput
.files
[0]);
2765 return request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
2766 progress
: L
.bind(function(btn
, ev
) {
2767 btn
.firstChild
.data
= '%.2f%%'.format((ev
.loaded
/ ev
.total
) * 100);
2769 }).then(L
.bind(function(path
, ev
, res
) {
2770 var reply
= res
.json();
2772 if (L
.isObject(reply
) && reply
.failure
)
2773 alert(_('Upload request failed: %s').format(reply
.message
));
2775 return this.handleSelect(path
, null, ev
);
2776 }, this, path
, ev
));
2780 handleDelete: function(path
, fileStat
, ev
) {
2781 var parent
= path
.replace(/\/[^\/]+$/, '') || '/',
2782 name
= path
.replace(/^.+\//, ''),
2785 ev
.preventDefault();
2787 if (fileStat
.type
== 'directory')
2788 msg
= _('Do you really want to recursively delete the directory "%s" ?').format(name
);
2790 msg
= _('Do you really want to delete "%s" ?').format(name
);
2793 var button
= this.node
.firstElementChild
,
2794 hidden
= this.node
.lastElementChild
;
2796 if (path
== hidden
.value
) {
2797 dom
.content(button
, _('Select file…'));
2801 return fs
.remove(path
).then(L
.bind(function(parent
, ev
) {
2802 return this.handleSelect(parent
, null, ev
);
2803 }, this, parent
, ev
)).catch(function(err
) {
2804 alert(_('Delete request failed: %s').format(err
.message
));
2810 renderUpload: function(path
, list
) {
2811 if (!this.options
.enable_upload
)
2817 'class': 'btn cbi-button-positive',
2818 'click': function(ev
) {
2819 var uploadForm
= ev
.target
.nextElementSibling
,
2820 fileInput
= uploadForm
.querySelector('input[type="file"]');
2822 ev
.target
.style
.display
= 'none';
2823 uploadForm
.style
.display
= '';
2826 }, _('Upload file…')),
2827 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2830 'style': 'display:none',
2831 'change': function(ev
) {
2832 var nameinput
= ev
.target
.parentNode
.querySelector('input[type="text"]'),
2833 uploadbtn
= ev
.target
.parentNode
.querySelector('button.cbi-button-save');
2835 nameinput
.value
= ev
.target
.value
.replace(/^.+[\/\\]/, '');
2836 uploadbtn
.disabled
= false;
2841 'click': function(ev
) {
2842 ev
.preventDefault();
2843 ev
.target
.previousElementSibling
.click();
2845 }, [ _('Browse…') ]),
2846 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2848 'class': 'btn cbi-button-save',
2849 'click': UI
.prototype.createHandlerFn(this, 'handleUpload', path
, list
),
2851 }, [ _('Upload file') ])
2857 renderListing: function(container
, path
, list
) {
2858 var breadcrumb
= E('p'),
2861 list
.sort(function(a
, b
) {
2862 var isDirA
= (a
.type
== 'directory'),
2863 isDirB
= (b
.type
== 'directory');
2865 if (isDirA
!= isDirB
)
2866 return isDirA
< isDirB
;
2868 return a
.name
> b
.name
;
2871 for (var i
= 0; i
< list
.length
; i
++) {
2872 if (!this.options
.show_hidden
&& list
[i
].name
.charAt(0) == '.')
2875 var entrypath
= this.canonicalizePath(path
+ '/' + list
[i
].name
),
2876 selected
= (entrypath
== this.node
.lastElementChild
.value
),
2877 mtime
= new Date(list
[i
].mtime
* 1000);
2879 rows
.appendChild(E('li', [
2880 E('div', { 'class': 'name' }, [
2881 this.iconForType(list
[i
].type
),
2885 'style': selected
? 'font-weight:bold' : null,
2886 'click': UI
.prototype.createHandlerFn(this, 'handleSelect',
2887 entrypath
, list
[i
].type
!= 'directory' ? list
[i
] : null)
2888 }, '%h'.format(list
[i
].name
))
2890 E('div', { 'class': 'mtime hide-xs' }, [
2891 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2892 mtime
.getFullYear(),
2893 mtime
.getMonth() + 1,
2900 selected
? E('button', {
2902 'click': UI
.prototype.createHandlerFn(this, 'handleReset')
2903 }, [ _('Deselect') ]) : '',
2904 this.options
.enable_remove
? E('button', {
2905 'class': 'btn cbi-button-negative',
2906 'click': UI
.prototype.createHandlerFn(this, 'handleDelete', entrypath
, list
[i
])
2907 }, [ _('Delete') ]) : ''
2912 if (!rows
.firstElementChild
)
2913 rows
.appendChild(E('em', _('No entries in this directory')));
2915 var dirs
= this.splitPath(path
),
2918 for (var i
= 0; i
< dirs
.length
; i
++) {
2919 cur
= cur
? cur
+ '/' + dirs
[i
] : dirs
[i
];
2920 dom
.append(breadcrumb
, [
2924 'click': UI
.prototype.createHandlerFn(this, 'handleSelect', cur
|| '/', null)
2925 }, dirs
[i
] != '' ? '%h'.format(dirs
[i
]) : E('em', '(root)')),
2929 dom
.content(container
, [
2932 E('div', { 'class': 'right' }, [
2933 this.renderUpload(path
, list
),
2937 'click': UI
.prototype.createHandlerFn(this, 'handleCancel')
2944 handleCancel: function(ev
) {
2945 var button
= this.node
.firstElementChild
,
2946 browser
= button
.nextElementSibling
;
2948 browser
.classList
.remove('open');
2949 button
.style
.display
= '';
2951 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2953 ev
.preventDefault();
2957 handleReset: function(ev
) {
2958 var button
= this.node
.firstElementChild
,
2959 hidden
= this.node
.lastElementChild
;
2962 dom
.content(button
, _('Select file…'));
2964 this.handleCancel(ev
);
2968 handleSelect: function(path
, fileStat
, ev
) {
2969 var browser
= dom
.parent(ev
.target
, '.cbi-filebrowser'),
2970 ul
= browser
.querySelector('ul');
2972 if (fileStat
== null) {
2973 dom
.content(ul
, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2974 L
.resolveDefault(fs
.list(path
), []).then(L
.bind(this.renderListing
, this, browser
, path
));
2977 var button
= this.node
.firstElementChild
,
2978 hidden
= this.node
.lastElementChild
;
2980 path
= this.canonicalizePath(path
);
2982 dom
.content(button
, [
2983 this.iconForType(fileStat
.type
),
2984 ' %s (%1000mB)'.format(this.truncatePath(path
), fileStat
.size
)
2987 browser
.classList
.remove('open');
2988 button
.style
.display
= '';
2989 hidden
.value
= path
;
2991 this.stat
= Object
.assign({ path
: path
}, fileStat
);
2992 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail
: this.stat
}));
2997 handleFileBrowser: function(ev
) {
2998 var button
= ev
.target
,
2999 browser
= button
.nextElementSibling
,
3000 path
= this.stat
? this.stat
.path
.replace(/\/[^\/]+$/, '') : (this.options
.initial_directory
|| this.options
.root_directory
);
3002 if (path
.indexOf(this.options
.root_directory
) != 0)
3003 path
= this.options
.root_directory
;
3005 ev
.preventDefault();
3007 return L
.resolveDefault(fs
.list(path
), []).then(L
.bind(function(button
, browser
, path
, list
) {
3008 document
.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl
) {
3009 dom
.findClassInstance(browserEl
).handleCancel(ev
);
3012 button
.style
.display
= 'none';
3013 browser
.classList
.add('open');
3015 return this.renderListing(browser
, path
, list
);
3016 }, this, button
, browser
, path
));
3020 getValue: function() {
3021 return this.node
.lastElementChild
.value
;
3025 setValue: function(value
) {
3026 this.node
.lastElementChild
.value
= value
;
3031 function scrubMenu(node
) {
3032 var hasSatisfiedChild
= false;
3034 if (L
.isObject(node
.children
)) {
3035 for (var k
in node
.children
) {
3036 var child
= scrubMenu(node
.children
[k
]);
3038 if (child
.title
&& !child
.firstchild_ineligible
)
3039 hasSatisfiedChild
= hasSatisfiedChild
|| child
.satisfied
;
3043 if (L
.isObject(node
.action
) &&
3044 node
.action
.type
== 'firstchild' &&
3045 hasSatisfiedChild
== false)
3046 node
.satisfied
= false;
3061 var UIMenu
= baseclass
.singleton(/** @lends LuCI.ui.menu.prototype */ {
3063 * @typedef {Object} MenuNode
3064 * @memberof LuCI.ui.menu
3066 * @property {string} name - The internal name of the node, as used in the URL
3067 * @property {number} order - The sort index of the menu node
3068 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
3069 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
3070 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
3071 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
3075 * Load and cache current menu tree.
3077 * @returns {Promise<LuCI.ui.menu.MenuNode>}
3078 * Returns a promise resolving to the root element of the menu tree.
3081 if (this.menu
== null)
3082 this.menu
= session
.getLocalData('menu');
3084 if (!L
.isObject(this.menu
)) {
3085 this.menu
= request
.get(L
.url('admin/menu')).then(L
.bind(function(menu
) {
3086 this.menu
= scrubMenu(menu
.json());
3087 session
.setLocalData('menu', this.menu
);
3093 return Promise
.resolve(this.menu
);
3097 * Flush the internal menu cache to force loading a new structure on the
3100 flushCache: function() {
3101 session
.setLocalData('menu', null);
3105 * @param {LuCI.ui.menu.MenuNode} [node]
3106 * The menu node to retrieve the children for. Defaults to the menu's
3107 * internal root node if omitted.
3109 * @returns {LuCI.ui.menu.MenuNode[]}
3110 * Returns an array of child menu nodes.
3112 getChildren: function(node
) {
3118 for (var k
in node
.children
) {
3119 if (!node
.children
.hasOwnProperty(k
))
3122 if (!node
.children
[k
].satisfied
)
3125 if (!node
.children
[k
].hasOwnProperty('title'))
3128 children
.push(Object
.assign(node
.children
[k
], { name
: k
}));
3131 return children
.sort(function(a
, b
) {
3132 var wA
= a
.order
|| 1000,
3133 wB
= b
.order
|| 1000;
3138 return a
.name
> b
.name
;
3149 * Provides high level UI helper functionality.
3150 * To import the class in views, use `'require ui'`, to import it in
3151 * external JavaScript, use `L.require("ui").then(...)`.
3153 var UI
= baseclass
.extend(/** @lends LuCI.ui.prototype */ {
3154 __init__: function() {
3155 modalDiv
= document
.body
.appendChild(
3156 dom
.create('div', { id
: 'modal_overlay' },
3157 dom
.create('div', { class: 'modal', role
: 'dialog', 'aria-modal': true })));
3159 tooltipDiv
= document
.body
.appendChild(
3160 dom
.create('div', { class: 'cbi-tooltip' }));
3162 /* setup old aliases */
3163 L
.showModal
= this.showModal
;
3164 L
.hideModal
= this.hideModal
;
3165 L
.showTooltip
= this.showTooltip
;
3166 L
.hideTooltip
= this.hideTooltip
;
3167 L
.itemlist
= this.itemlist
;
3169 document
.addEventListener('mouseover', this.showTooltip
.bind(this), true);
3170 document
.addEventListener('mouseout', this.hideTooltip
.bind(this), true);
3171 document
.addEventListener('focus', this.showTooltip
.bind(this), true);
3172 document
.addEventListener('blur', this.hideTooltip
.bind(this), true);
3174 document
.addEventListener('luci-loaded', this.tabs
.init
.bind(this.tabs
));
3175 document
.addEventListener('luci-loaded', this.changes
.init
.bind(this.changes
));
3176 document
.addEventListener('uci-loaded', this.changes
.init
.bind(this.changes
));
3180 * Display a modal overlay dialog with the specified contents.
3182 * The modal overlay dialog covers the current view preventing interaction
3183 * with the underlying view contents. Only one modal dialog instance can
3184 * be opened. Invoking showModal() while a modal dialog is already open will
3185 * replace the open dialog with a new one having the specified contents.
3187 * Additional CSS class names may be passed to influence the appearence of
3188 * the dialog. Valid values for the classes depend on the underlying theme.
3190 * @see LuCI.dom.content
3192 * @param {string} [title]
3193 * The title of the dialog. If `null`, no title element will be rendered.
3195 * @param {*} contents
3196 * The contents to add to the modal dialog. This should be a DOM node or
3197 * a document fragment in most cases. The value is passed as-is to the
3198 * `dom.content()` function - refer to its documentation for applicable
3201 * @param {...string} [classes]
3202 * A number of extra CSS class names which are set on the modal dialog
3206 * Returns a DOM Node representing the modal dialog element.
3208 showModal: function(title
, children
/* , ... */) {
3209 var dlg
= modalDiv
.firstElementChild
;
3211 dlg
.setAttribute('class', 'modal');
3213 for (var i
= 2; i
< arguments
.length
; i
++)
3214 dlg
.classList
.add(arguments
[i
]);
3216 dom
.content(dlg
, dom
.create('h4', {}, title
));
3217 dom
.append(dlg
, children
);
3219 document
.body
.classList
.add('modal-overlay-active');
3220 modalDiv
.scrollTop
= 0;
3226 * Close the open modal overlay dialog.
3228 * This function will close an open modal dialog and restore the normal view
3229 * behaviour. It has no effect if no modal dialog is currently open.
3231 * Note that this function is stand-alone, it does not rely on `this` and
3232 * will not invoke other class functions so it suitable to be used as event
3233 * handler as-is without the need to bind it first.
3235 hideModal: function() {
3236 document
.body
.classList
.remove('modal-overlay-active');
3240 showTooltip: function(ev
) {
3241 var target
= findParent(ev
.target
, '[data-tooltip]');
3246 if (tooltipTimeout
!== null) {
3247 window
.clearTimeout(tooltipTimeout
);
3248 tooltipTimeout
= null;
3251 var rect
= target
.getBoundingClientRect(),
3252 x
= rect
.left
+ window
.pageXOffset
,
3253 y
= rect
.top
+ rect
.height
+ window
.pageYOffset
,
3256 tooltipDiv
.className
= 'cbi-tooltip';
3257 tooltipDiv
.innerHTML
= '▲ ';
3258 tooltipDiv
.firstChild
.data
+= target
.getAttribute('data-tooltip');
3260 if (target
.hasAttribute('data-tooltip-style'))
3261 tooltipDiv
.classList
.add(target
.getAttribute('data-tooltip-style'));
3263 if ((y
+ tooltipDiv
.offsetHeight
) > (window
.innerHeight
+ window
.pageYOffset
))
3266 var dropdown
= target
.querySelector('ul.dropdown[style]:first-child');
3268 if (dropdown
&& dropdown
.style
.top
)
3272 y
-= (tooltipDiv
.offsetHeight
+ target
.offsetHeight
);
3273 tooltipDiv
.firstChild
.data
= '▼ ' + tooltipDiv
.firstChild
.data
.substr(2);
3276 tooltipDiv
.style
.top
= y
+ 'px';
3277 tooltipDiv
.style
.left
= x
+ 'px';
3278 tooltipDiv
.style
.opacity
= 1;
3280 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-open', {
3282 detail
: { target
: target
}
3287 hideTooltip: function(ev
) {
3288 if (ev
.target
=== tooltipDiv
|| ev
.relatedTarget
=== tooltipDiv
||
3289 tooltipDiv
.contains(ev
.target
) || tooltipDiv
.contains(ev
.relatedTarget
))
3292 if (tooltipTimeout
!== null) {
3293 window
.clearTimeout(tooltipTimeout
);
3294 tooltipTimeout
= null;
3297 tooltipDiv
.style
.opacity
= 0;
3298 tooltipTimeout
= window
.setTimeout(function() { tooltipDiv
.removeAttribute('style'); }, 250);
3300 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-close', { bubbles
: true }));
3304 * Add a notification banner at the top of the current view.
3306 * A notification banner is an alert message usually displayed at the
3307 * top of the current view, spanning the entire availibe width.
3308 * Notification banners will stay in place until dismissed by the user.
3309 * Multiple banners may be shown at the same time.
3311 * Additional CSS class names may be passed to influence the appearence of
3312 * the banner. Valid values for the classes depend on the underlying theme.
3314 * @see LuCI.dom.content
3316 * @param {string} [title]
3317 * The title of the notification banner. If `null`, no title element
3320 * @param {*} contents
3321 * The contents to add to the notification banner. This should be a DOM
3322 * node or a document fragment in most cases. The value is passed as-is
3323 * to the `dom.content()` function - refer to its documentation for
3324 * applicable values.
3326 * @param {...string} [classes]
3327 * A number of extra CSS class names which are set on the notification
3331 * Returns a DOM Node representing the notification banner element.
3333 addNotification: function(title
, children
/*, ... */) {
3334 var mc
= document
.querySelector('#maincontent') || document
.body
;
3335 var msg
= E('div', {
3336 'class': 'alert-message fade-in',
3337 'style': 'display:flex',
3338 'transitionend': function(ev
) {
3339 var node
= ev
.currentTarget
;
3340 if (node
.parentNode
&& node
.classList
.contains('fade-out'))
3341 node
.parentNode
.removeChild(node
);
3344 E('div', { 'style': 'flex:10' }),
3345 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3348 'style': 'margin-left:auto; margin-top:auto',
3349 'click': function(ev
) {
3350 dom
.parent(ev
.target
, '.alert-message').classList
.add('fade-out');
3353 }, [ _('Dismiss') ])
3358 dom
.append(msg
.firstElementChild
, E('h4', {}, title
));
3360 dom
.append(msg
.firstElementChild
, children
);
3362 for (var i
= 2; i
< arguments
.length
; i
++)
3363 msg
.classList
.add(arguments
[i
]);
3365 mc
.insertBefore(msg
, mc
.firstElementChild
);
3371 * Display or update an header area indicator.
3373 * An indicator is a small label displayed in the header area of the screen
3374 * providing few amounts of status information such as item counts or state
3375 * toggle indicators.
3377 * Multiple indicators may be shown at the same time and indicator labels
3378 * may be made clickable to display extended information or to initiate
3381 * Indicators can either use a default `active` or a less accented `inactive`
3382 * style which is useful for indicators representing state toggles.
3384 * @param {string} id
3385 * The ID of the indicator. If an indicator with the given ID already exists,
3386 * it is updated with the given label and style.
3388 * @param {string} label
3389 * The text to display in the indicator label.
3391 * @param {function} [handler]
3392 * A handler function to invoke when the indicator label is clicked/touched
3393 * by the user. If omitted, the indicator is not clickable/touchable.
3395 * Note that this parameter only applies to new indicators, when updating
3396 * existing labels it is ignored.
3398 * @param {string} [style=active]
3399 * The indicator style to use. May be either `active` or `inactive`.
3401 * @returns {boolean}
3402 * Returns `true` when the indicator has been updated or `false` when no
3403 * changes were made.
3405 showIndicator: function(id
, label
, handler
, style
) {
3406 if (indicatorDiv
== null) {
3407 indicatorDiv
= document
.body
.querySelector('#indicators');
3409 if (indicatorDiv
== null)
3413 var handlerFn
= (typeof(handler
) == 'function') ? handler
: null,
3414 indicatorElem
= indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
));
3416 if (indicatorElem
== null) {
3417 var beforeElem
= null;
3419 for (beforeElem
= indicatorDiv
.firstElementChild
;
3421 beforeElem
= beforeElem
.nextElementSibling
)
3422 if (beforeElem
.getAttribute('data-indicator') > id
)
3425 indicatorElem
= indicatorDiv
.insertBefore(E('span', {
3426 'data-indicator': id
,
3427 'data-clickable': handlerFn
? true : null,
3429 }, ['']), beforeElem
);
3432 if (label
== indicatorElem
.firstChild
.data
&& style
== indicatorElem
.getAttribute('data-style'))
3435 indicatorElem
.firstChild
.data
= label
;
3436 indicatorElem
.setAttribute('data-style', (style
== 'inactive') ? 'inactive' : 'active');
3441 * Remove an header area indicator.
3443 * This function removes the given indicator label from the header indicator
3444 * area. When the given indicator is not found, this function does nothing.
3446 * @param {string} id
3447 * The ID of the indicator to remove.
3449 * @returns {boolean}
3450 * Returns `true` when the indicator has been removed or `false` when the
3451 * requested indicator was not found.
3453 hideIndicator: function(id
) {
3454 var indicatorElem
= indicatorDiv
? indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
)) : null;
3456 if (indicatorElem
== null)
3459 indicatorDiv
.removeChild(indicatorElem
);
3464 * Formats a series of label/value pairs into list-like markup.
3466 * This function transforms a flat array of alternating label and value
3467 * elements into a list-like markup, using the values in `separators` as
3468 * separators and appends the resulting nodes to the given parent DOM node.
3470 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3471 * `<strong>` element and the value corresponding to the label are
3472 * subsequently wrapped into a `<span class="nowrap">` element.
3474 * The resulting `<span>` element tuples are joined by the given separators
3475 * to form the final markup which is appened to the given parent DOM node.
3477 * @param {Node} node
3478 * The parent DOM node to append the markup to. Any previous child elements
3481 * @param {Array<*>} items
3482 * An alternating array of labels and values. The label values will be
3483 * converted to plain strings, the values are used as-is and may be of
3484 * any type accepted by `LuCI.dom.content()`.
3486 * @param {*|Array<*>} [separators=[E('br')]]
3487 * A single value or an array of separator values to separate each
3488 * label/value pair with. The function will cycle through the separators
3489 * when joining the pairs. If omitted, the default separator is a sole HTML
3490 * `<br>` element. Separator values are used as-is and may be of any type
3491 * accepted by `LuCI.dom.content()`.
3494 * Returns the parent DOM node the formatted markup has been added to.
3496 itemlist: function(node
, items
, separators
) {
3499 if (!Array
.isArray(separators
))
3500 separators
= [ separators
|| E('br') ];
3502 for (var i
= 0; i
< items
.length
; i
+= 2) {
3503 if (items
[i
+1] !== null && items
[i
+1] !== undefined) {
3504 var sep
= separators
[(i
/2) % separators
.length
],
3507 children
.push(E('span', { class: 'nowrap' }, [
3508 items
[i
] ? E('strong', items
[i
] + ': ') : '',
3512 if ((i
+2) < items
.length
)
3513 children
.push(dom
.elem(sep
) ? sep
.cloneNode(true) : sep
);
3517 dom
.content(node
, children
);
3528 * The `tabs` class handles tab menu groups used throughout the view area.
3529 * It takes care of setting up tab groups, tracking their state and handling
3532 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3533 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3534 * external JavaScript, use `L.require("ui").then(...)` and access the
3535 * `tabs` property of the class instance value.
3537 tabs
: baseclass
.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3540 var groups
= [], prevGroup
= null, currGroup
= null;
3542 document
.querySelectorAll('[data-tab]').forEach(function(tab
) {
3543 var parent
= tab
.parentNode
;
3545 if (dom
.matches(tab
, 'li') && dom
.matches(parent
, 'ul.cbi-tabmenu'))
3548 if (!parent
.hasAttribute('data-tab-group'))
3549 parent
.setAttribute('data-tab-group', groups
.length
);
3551 currGroup
= +parent
.getAttribute('data-tab-group');
3553 if (currGroup
!== prevGroup
) {
3554 prevGroup
= currGroup
;
3556 if (!groups
[currGroup
])
3557 groups
[currGroup
] = [];
3560 groups
[currGroup
].push(tab
);
3563 for (var i
= 0; i
< groups
.length
; i
++)
3564 this.initTabGroup(groups
[i
]);
3566 document
.addEventListener('dependency-update', this.updateTabs
.bind(this));
3572 * Initializes a new tab group from the given tab pane collection.
3574 * This function cycles through the given tab pane DOM nodes, extracts
3575 * their tab IDs, titles and active states, renders a corresponding
3576 * tab menu and prepends it to the tab panes common parent DOM node.
3578 * The tab menu labels will be set to the value of the `data-tab-title`
3579 * attribute of each corresponding pane. The last pane with the
3580 * `data-tab-active` attribute set to `true` will be selected by default.
3582 * If no pane is marked as active, the first one will be preselected.
3585 * @memberof LuCI.ui.tabs
3586 * @param {Array<Node>|NodeList} panes
3587 * A collection of tab panes to build a tab group menu for. May be a
3588 * plain array of DOM nodes or a NodeList collection, such as the result
3589 * of a `querySelectorAll()` call or the `.childNodes` property of a
3592 initTabGroup: function(panes
) {
3593 if (typeof(panes
) != 'object' || !('length' in panes
) || panes
.length
=== 0)
3596 var menu
= E('ul', { 'class': 'cbi-tabmenu' }),
3597 group
= panes
[0].parentNode
,
3598 groupId
= +group
.getAttribute('data-tab-group'),
3601 if (group
.getAttribute('data-initialized') === 'true')
3604 for (var i
= 0, pane
; pane
= panes
[i
]; i
++) {
3605 var name
= pane
.getAttribute('data-tab'),
3606 title
= pane
.getAttribute('data-tab-title'),
3607 active
= pane
.getAttribute('data-tab-active') === 'true';
3609 menu
.appendChild(E('li', {
3610 'style': this.isEmptyPane(pane
) ? 'display:none' : null,
3611 'class': active
? 'cbi-tab' : 'cbi-tab-disabled',
3615 'click': this.switchTab
.bind(this)
3622 group
.parentNode
.insertBefore(menu
, group
);
3623 group
.setAttribute('data-initialized', true);
3625 if (selected
=== null) {
3626 selected
= this.getActiveTabId(panes
[0]);
3628 if (selected
< 0 || selected
>= panes
.length
|| this.isEmptyPane(panes
[selected
])) {
3629 for (var i
= 0; i
< panes
.length
; i
++) {
3630 if (!this.isEmptyPane(panes
[i
])) {
3637 menu
.childNodes
[selected
].classList
.add('cbi-tab');
3638 menu
.childNodes
[selected
].classList
.remove('cbi-tab-disabled');
3639 panes
[selected
].setAttribute('data-tab-active', 'true');
3641 this.setActiveTabId(panes
[selected
], selected
);
3644 requestAnimationFrame(L
.bind(function(pane
) {
3645 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', {
3646 detail
: { tab
: pane
.getAttribute('data-tab') }
3648 }, this, panes
[selected
]));
3650 this.updateTabs(group
);
3654 * Checks whether the given tab pane node is empty.
3657 * @memberof LuCI.ui.tabs
3658 * @param {Node} pane
3659 * The tab pane to check.
3661 * @returns {boolean}
3662 * Returns `true` if the pane is empty, else `false`.
3664 isEmptyPane: function(pane
) {
3665 return dom
.isEmpty(pane
, function(n
) { return n
.classList
.contains('cbi-tab-descr') });
3669 getPathForPane: function(pane
) {
3670 var path
= [], node
= null;
3672 for (node
= pane
? pane
.parentNode
: null;
3673 node
!= null && node
.hasAttribute
!= null;
3674 node
= node
.parentNode
)
3676 if (node
.hasAttribute('data-tab'))
3677 path
.unshift(node
.getAttribute('data-tab'));
3678 else if (node
.hasAttribute('data-section-id'))
3679 path
.unshift(node
.getAttribute('data-section-id'));
3682 return path
.join('/');
3686 getActiveTabState: function() {
3687 var page
= document
.body
.getAttribute('data-page'),
3688 state
= session
.getLocalData('tab');
3690 if (L
.isObject(state
) && state
.page
=== page
&& L
.isObject(state
.paths
))
3693 session
.setLocalData('tab', null);
3695 return { page
: page
, paths
: {} };
3699 getActiveTabId: function(pane
) {
3700 var path
= this.getPathForPane(pane
);
3701 return +this.getActiveTabState().paths
[path
] || 0;
3705 setActiveTabId: function(pane
, tabIndex
) {
3706 var path
= this.getPathForPane(pane
),
3707 state
= this.getActiveTabState();
3709 state
.paths
[path
] = tabIndex
;
3711 return session
.setLocalData('tab', state
);
3715 updateTabs: function(ev
, root
) {
3716 (root
|| document
).querySelectorAll('[data-tab-title]').forEach(L
.bind(function(pane
) {
3717 var menu
= pane
.parentNode
.previousElementSibling
,
3718 tab
= menu
? menu
.querySelector('[data-tab="%s"]'.format(pane
.getAttribute('data-tab'))) : null,
3719 n_errors
= pane
.querySelectorAll('.cbi-input-invalid').length
;
3724 if (this.isEmptyPane(pane
)) {
3725 tab
.style
.display
= 'none';
3726 tab
.classList
.remove('flash');
3728 else if (tab
.style
.display
=== 'none') {
3729 tab
.style
.display
= '';
3730 requestAnimationFrame(function() { tab
.classList
.add('flash') });
3734 tab
.setAttribute('data-errors', n_errors
);
3735 tab
.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors
));
3736 tab
.setAttribute('data-tooltip-style', 'error');
3739 tab
.removeAttribute('data-errors');
3740 tab
.removeAttribute('data-tooltip');
3746 switchTab: function(ev
) {
3747 var tab
= ev
.target
.parentNode
,
3748 name
= tab
.getAttribute('data-tab'),
3749 menu
= tab
.parentNode
,
3750 group
= menu
.nextElementSibling
,
3751 groupId
= +group
.getAttribute('data-tab-group'),
3754 ev
.preventDefault();
3756 if (!tab
.classList
.contains('cbi-tab-disabled'))
3759 menu
.querySelectorAll('[data-tab]').forEach(function(tab
) {
3760 tab
.classList
.remove('cbi-tab');
3761 tab
.classList
.remove('cbi-tab-disabled');
3763 tab
.getAttribute('data-tab') === name
? 'cbi-tab' : 'cbi-tab-disabled');
3766 group
.childNodes
.forEach(function(pane
) {
3767 if (dom
.matches(pane
, '[data-tab]')) {
3768 if (pane
.getAttribute('data-tab') === name
) {
3769 pane
.setAttribute('data-tab-active', 'true');
3770 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', { detail
: { tab
: name
} }));
3771 UI
.prototype.tabs
.setActiveTabId(pane
, index
);
3774 pane
.setAttribute('data-tab-active', 'false');
3784 * @typedef {Object} FileUploadReply
3787 * @property {string} name - Name of the uploaded file without directory components
3788 * @property {number} size - Size of the uploaded file in bytes
3789 * @property {string} checksum - The MD5 checksum of the received file data
3790 * @property {string} sha256sum - The SHA256 checksum of the received file data
3794 * Display a modal file upload prompt.
3796 * This function opens a modal dialog prompting the user to select and
3797 * upload a file to a predefined remote destination path.
3799 * @param {string} path
3800 * The remote file path to upload the local file to.
3802 * @param {Node} [progessStatusNode]
3803 * An optional DOM text node whose content text is set to the progress
3804 * percentage value during file upload.
3806 * @returns {Promise<LuCI.ui.FileUploadReply>}
3807 * Returns a promise resolving to a file upload status object on success
3808 * or rejecting with an error in case the upload failed or has been
3809 * cancelled by the user.
3811 uploadFile: function(path
, progressStatusNode
) {
3812 return new Promise(function(resolveFn
, rejectFn
) {
3813 UI
.prototype.showModal(_('Uploading file…'), [
3814 E('p', _('Please select the file to upload.')),
3815 E('div', { 'style': 'display:flex' }, [
3816 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3819 style
: 'display:none',
3820 change: function(ev
) {
3821 var modal
= dom
.parent(ev
.target
, '.modal'),
3822 body
= modal
.querySelector('p'),
3823 upload
= modal
.querySelector('.cbi-button-action.important'),
3824 file
= ev
.currentTarget
.files
[0];
3831 E('li', {}, [ '%s: %s'.format(_('Name'), file
.name
.replace(/^.*[\\\/]/, '')) ]),
3832 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file
.size
) ])
3836 upload
.disabled
= false;
3842 'click': function(ev
) {
3843 ev
.target
.previousElementSibling
.click();
3845 }, [ _('Browse…') ])
3847 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3850 'click': function() {
3851 UI
.prototype.hideModal();
3852 rejectFn(new Error('Upload has been cancelled'));
3854 }, [ _('Cancel') ]),
3857 'class': 'btn cbi-button-action important',
3859 'click': function(ev
) {
3860 var input
= dom
.parent(ev
.target
, '.modal').querySelector('input[type="file"]');
3862 if (!input
.files
[0])
3865 var progress
= E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3867 UI
.prototype.showModal(_('Uploading file…'), [ progress
]);
3869 var data
= new FormData();
3871 data
.append('sessionid', rpc
.getSessionID());
3872 data
.append('filename', path
);
3873 data
.append('filedata', input
.files
[0]);
3875 var filename
= input
.files
[0].name
;
3877 request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
3879 progress: function(pev
) {
3880 var percent
= (pev
.loaded
/ pev
.total
) * 100;
3882 if (progressStatusNode
)
3883 progressStatusNode
.data
= '%.2f%%'.format(percent
);
3885 progress
.setAttribute('title', '%.2f%%'.format(percent
));
3886 progress
.firstElementChild
.style
.width
= '%.2f%%'.format(percent
);
3888 }).then(function(res
) {
3889 var reply
= res
.json();
3891 UI
.prototype.hideModal();
3893 if (L
.isObject(reply
) && reply
.failure
) {
3894 UI
.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply
.message
)));
3895 rejectFn(new Error(reply
.failure
));
3898 reply
.name
= filename
;
3902 UI
.prototype.hideModal();
3914 * Perform a device connectivity test.
3916 * Attempt to fetch a well known ressource from the remote device via HTTP
3917 * in order to test connectivity. This function is mainly useful to wait
3918 * for the router to come back online after a reboot or reconfiguration.
3920 * @param {string} [proto=http]
3921 * The protocol to use for fetching the resource. May be either `http`
3922 * (the default) or `https`.
3924 * @param {string} [host=window.location.host]
3925 * Override the host address to probe. By default the current host as seen
3926 * in the address bar is probed.
3928 * @returns {Promise<Event>}
3929 * Returns a promise resolving to a `load` event in case the device is
3930 * reachable or rejecting with an `error` event in case it is not reachable
3931 * or rejecting with `null` when the connectivity check timed out.
3933 pingDevice: function(proto
, ipaddr
) {
3934 var target
= '%s://%s%s?%s'.format(proto
|| 'http', ipaddr
|| window
.location
.host
, L
.resource('icons/loading.gif'), Math
.random());
3936 return new Promise(function(resolveFn
, rejectFn
) {
3937 var img
= new Image();
3939 img
.onload
= resolveFn
;
3940 img
.onerror
= rejectFn
;
3942 window
.setTimeout(rejectFn
, 1000);
3949 * Wait for device to come back online and reconnect to it.
3951 * Poll each given hostname or IP address and navigate to it as soon as
3952 * one of the addresses becomes reachable.
3954 * @param {...string} [hosts=[window.location.host]]
3955 * The list of IP addresses and host names to check for reachability.
3956 * If omitted, the current value of `window.location.host` is used by
3959 awaitReconnect: function(/* ... */) {
3960 var ipaddrs
= arguments
.length
? arguments
: [ window
.location
.host
];
3962 window
.setTimeout(L
.bind(function() {
3963 poll
.add(L
.bind(function() {
3964 var tasks
= [], reachable
= false;
3966 for (var i
= 0; i
< 2; i
++)
3967 for (var j
= 0; j
< ipaddrs
.length
; j
++)
3968 tasks
.push(this.pingDevice(i
? 'https' : 'http', ipaddrs
[j
])
3969 .then(function(ev
) { reachable
= ev
.target
.src
.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3971 return Promise
.all(tasks
).then(function() {
3974 window
.location
= reachable
;
3987 * The `changes` class encapsulates logic for visualizing, applying,
3988 * confirming and reverting staged UCI changesets.
3990 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3991 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3992 * external JavaScript, use `L.require("ui").then(...)` and access the
3993 * `changes` property of the class instance value.
3995 changes
: baseclass
.singleton(/* @lends LuCI.ui.changes.prototype */ {
3997 if (!L
.env
.sessionid
)
4000 return uci
.changes().then(L
.bind(this.renderChangeIndicator
, this));
4004 * Set the change count indicator.
4006 * This function updates or hides the UCI change count indicator,
4007 * depending on the passed change count. When the count is greater
4008 * than 0, the change indicator is displayed or updated, otherwise it
4012 * @memberof LuCI.ui.changes
4013 * @param {number} numChanges
4014 * The number of changes to indicate.
4016 setIndicator: function(n
) {
4018 UI
.prototype.showIndicator('uci-changes',
4019 '%s: %d'.format(_('Unsaved Changes'), n
),
4020 L
.bind(this.displayChanges
, this));
4023 UI
.prototype.hideIndicator('uci-changes');
4028 * Update the change count indicator.
4030 * This function updates the UCI change count indicator from the given
4031 * UCI changeset structure.
4034 * @memberof LuCI.ui.changes
4035 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
4036 * The UCI changeset to count.
4038 renderChangeIndicator: function(changes
) {
4041 for (var config
in changes
)
4042 if (changes
.hasOwnProperty(config
))
4043 n_changes
+= changes
[config
].length
;
4045 this.changes
= changes
;
4046 this.setIndicator(n_changes
);
4051 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
4052 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
4053 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
4054 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
4055 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
4056 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
4057 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
4058 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
4059 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
4060 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
4064 * Display the current changelog.
4066 * Open a modal dialog visualizing the currently staged UCI changes
4067 * and offer options to revert or apply the shown changes.
4070 * @memberof LuCI.ui.changes
4072 displayChanges: function() {
4073 var list
= E('div', { 'class': 'uci-change-list' }),
4074 dlg
= UI
.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
4075 E('div', { 'class': 'cbi-section' }, [
4076 E('strong', _('Legend:')),
4077 E('div', { 'class': 'uci-change-legend' }, [
4078 E('div', { 'class': 'uci-change-legend-label' }, [
4079 E('ins', ' '), ' ', _('Section added') ]),
4080 E('div', { 'class': 'uci-change-legend-label' }, [
4081 E('del', ' '), ' ', _('Section removed') ]),
4082 E('div', { 'class': 'uci-change-legend-label' }, [
4083 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
4084 E('div', { 'class': 'uci-change-legend-label' }, [
4085 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
4087 E('div', { 'class': 'right' }, [
4090 'click': UI
.prototype.hideModal
4091 }, [ _('Close') ]), ' ',
4093 'class': 'cbi-button cbi-button-positive important',
4094 'click': L
.bind(this.apply
, this, true)
4095 }, [ _('Save & Apply') ]), ' ',
4097 'class': 'cbi-button cbi-button-reset',
4098 'click': L
.bind(this.revert
, this)
4099 }, [ _('Revert') ])])])
4102 for (var config
in this.changes
) {
4103 if (!this.changes
.hasOwnProperty(config
))
4106 list
.appendChild(E('h5', '# /etc/config/%s'.format(config
)));
4108 for (var i
= 0, added
= null; i
< this.changes
[config
].length
; i
++) {
4109 var chg
= this.changes
[config
][i
],
4110 tpl
= this.changeTemplates
['%s-%d'.format(chg
[0], chg
.length
)];
4112 list
.appendChild(E(tpl
.replace(/%([01234])/g, function(m0
, m1
) {
4118 if (added
!= null && chg
[1] == added
[0])
4119 return '@' + added
[1] + '[-1]';
4124 return "'%h'".format(chg
[3].replace(/'/g, "'\"'\"'"));
4131 if (chg[0] == 'add')
4132 added = [ chg[1], chg[2] ];
4136 list.appendChild(E('br'));
4137 dlg.classList.add('uci-dialog');
4141 displayStatus: function(type, content) {
4143 var message = UI.prototype.showModal('', '');
4145 message.classList.add('alert-message');
4146 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4149 dom.content(message, content);
4151 if (!this.was_polling) {
4152 this.was_polling = request.poll.active();
4153 request.poll.stop();
4157 UI.prototype.hideModal();
4159 if (this.was_polling)
4160 request.poll.start();
4165 rollback: function(checked) {
4167 this.displayStatus('warning spinning',
4168 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4169 .format(L.env.apply_rollback)));
4171 var call = function(r, data, duration) {
4172 if (r.status === 204) {
4173 UI.prototype.changes.displayStatus('warning', [
4174 E('h4', _('Configuration changes have been rolled back!')),
4175 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)),
4176 E('div', { 'class': 'right' }, [
4179 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4180 }, [ _('Dismiss') ]), ' ',
4182 'class': 'btn cbi-button-action important',
4183 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4184 }, [ _('Revert changes') ]), ' ',
4186 'class': 'btn cbi-button-negative important',
4187 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4188 }, [ _('Apply unchecked') ])
4195 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4196 window.setTimeout(function() {
4197 request.request(L.url('admin/uci/confirm'), {
4199 timeout: L.env.apply_timeout * 1000,
4200 query: { sid: L.env.sessionid, token: L.env.token }
4205 call({ status: 0 });
4208 this.displayStatus('warning', [
4209 E('h4', _('Device unreachable!')),
4210 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.'))
4216 confirm: function(checked, deadline, override_token) {
4218 var ts = Date.now();
4220 this.displayStatus('notice');
4223 this.confirm_auth = { token: override_token };
4225 var call = function(r, data, duration) {
4226 if (Date.now() >= deadline) {
4227 window.clearTimeout(tt);
4228 UI.prototype.changes.rollback(checked);
4231 else if (r && (r.status === 200 || r.status === 204)) {
4232 document.dispatchEvent(new CustomEvent('uci-applied'));
4234 UI.prototype.changes.setIndicator(0);
4235 UI.prototype.changes.displayStatus('notice',
4236 E('p', _('Configuration changes applied.')));
4238 window.clearTimeout(tt);
4239 window.setTimeout(function() {
4240 //UI.prototype.changes.displayStatus(false);
4241 window.location = window.location.href.split('#')[0];
4242 }, L.env.apply_display * 1000);
4247 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4248 window.setTimeout(function() {
4249 request.request(L.url('admin/uci/confirm'), {
4251 timeout: L.env.apply_timeout * 1000,
4252 query: UI.prototype.changes.confirm_auth
4253 }).then(call, call);
4257 var tick = function() {
4258 var now = Date.now();
4260 UI.prototype.changes.displayStatus('notice spinning',
4261 E('p', _('Applying configuration changes… %ds')
4262 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4264 if (now >= deadline)
4267 tt = window.setTimeout(tick, 1000 - (now - ts));
4273 /* wait a few seconds for the settings to become effective */
4274 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4278 * Apply the staged configuration changes.
4280 * Start applying staged configuration changes and open a modal dialog
4281 * with a progress indication to prevent interaction with the view
4282 * during the apply process. The modal dialog will be automatically
4283 * closed and the current view reloaded once the apply process is
4287 * @memberof LuCI.ui.changes
4288 * @param {boolean} [checked=false]
4289 * Whether to perform a checked (`true`) configuration apply or an
4290 * unchecked (`false`) one.
4292 * In case of a checked apply, the configuration changes must be
4293 * confirmed within a specific time interval, otherwise the device
4294 * will begin to roll back the changes in order to restore the previous
4297 apply: function(checked) {
4298 this.displayStatus('notice spinning',
4299 E('p', _('Starting configuration apply…')));
4301 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4303 query: { sid: L.env.sessionid, token: L.env.token }
4304 }).then(function(r) {
4305 if (r.status === (checked ? 200 : 204)) {
4306 var tok = null; try { tok = r.json(); } catch(e) {}
4307 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4308 UI.prototype.changes.confirm_auth = tok;
4310 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4312 else if (checked && r.status === 204) {
4313 UI.prototype.changes.displayStatus('notice',
4314 E('p', _('There are no changes to apply')));
4316 window.setTimeout(function() {
4317 UI.prototype.changes.displayStatus(false);
4318 }, L.env.apply_display * 1000);
4321 UI.prototype.changes.displayStatus('warning',
4322 E('p', _('Apply request failed with status <code>%h</code>')
4323 .format(r.responseText || r.statusText || r.status)));
4325 window.setTimeout(function() {
4326 UI.prototype.changes.displayStatus(false);
4327 }, L.env.apply_display * 1000);
4333 * Revert the staged configuration changes.
4335 * Start reverting staged configuration changes and open a modal dialog
4336 * with a progress indication to prevent interaction with the view
4337 * during the revert process. The modal dialog will be automatically
4338 * closed and the current view reloaded once the revert process is
4342 * @memberof LuCI.ui.changes
4344 revert: function() {
4345 this.displayStatus('notice spinning',
4346 E('p', _('Reverting configuration…')));
4348 request.request(L.url('admin/uci/revert'), {
4350 query: { sid: L.env.sessionid, token: L.env.token }
4351 }).then(function(r) {
4352 if (r.status === 200) {
4353 document.dispatchEvent(new CustomEvent('uci-reverted'));
4355 UI.prototype.changes.setIndicator(0);
4356 UI.prototype.changes.displayStatus('notice',
4357 E('p', _('Changes have been reverted.')));
4359 window.setTimeout(function() {
4360 //UI.prototype.changes.displayStatus(false);
4361 window.location = window.location.href.split('#')[0];
4362 }, L.env.apply_display * 1000);
4365 UI.prototype.changes.displayStatus('warning',
4366 E('p', _('Revert request failed with status <code>%h</code>')
4367 .format(r.statusText || r.status)));
4369 window.setTimeout(function() {
4370 UI.prototype.changes.displayStatus(false);
4371 }, L.env.apply_display * 1000);
4378 * Add validation constraints to an input element.
4380 * Compile the given type expression and optional validator function into
4381 * a validation function and bind it to the specified input element events.
4383 * @param {Node} field
4384 * The DOM input element node to bind the validation constraints to.
4386 * @param {string} type
4387 * The datatype specification to describe validation constraints.
4388 * Refer to the `LuCI.validation` class documentation for details.
4390 * @param {boolean} [optional=false]
4391 * Specifies whether empty values are allowed (`true`) or not (`false`).
4392 * If an input element is not marked optional it must not be empty,
4393 * otherwise it will be marked as invalid.
4395 * @param {function} [vfunc]
4396 * Specifies a custom validation function which is invoked after the
4397 * other validation constraints are applied. The validation must return
4398 * `true` to accept the passed value. Any other return type is converted
4399 * to a string and treated as validation error message.
4401 * @param {...string} [events=blur, keyup]
4402 * The list of events to bind. Each received event will trigger a field
4403 * validation. If omitted, the `keyup` and `blur` events are bound by
4406 * @returns {function}
4407 * Returns the compiled validator function which can be used to manually
4408 * trigger field validation or to bind it to further events.
4410 * @see LuCI.validation
4412 addValidator: function(field, type, optional, vfunc /*, ... */) {
4416 var events = this.varargs(arguments, 3);
4417 if (events.length == 0)
4418 events.push('blur', 'keyup');
4421 var cbiValidator = validation.create(field, type, optional, vfunc),
4422 validatorFn = cbiValidator.validate.bind(cbiValidator);
4424 for (var i = 0; i < events.length; i++)
4425 field.addEventListener(events[i], validatorFn);
4435 * Create a pre-bound event handler function.
4437 * Generate and bind a function suitable for use in event handlers. The
4438 * generated function automatically disables the event source element
4439 * and adds an active indication to it by adding appropriate CSS classes.
4441 * It will also await any promises returned by the wrapped function and
4442 * re-enable the source element after the promises ran to completion.
4445 * The `this` context to use for the wrapped function.
4447 * @param {function|string} fn
4448 * Specifies the function to wrap. In case of a function value, the
4449 * function is used as-is. If a string is specified instead, it is looked
4450 * up in `ctx` to obtain the function to wrap. In both cases the bound
4451 * function will be invoked with `ctx` as `this` context
4453 * @param {...*} extra_args
4454 * Any further parameter as passed as-is to the bound event handler
4455 * function in the same order as passed to `createHandlerFn()`.
4457 * @returns {function|null}
4458 * Returns the pre-bound handler function which is suitable to be passed
4459 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4460 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4461 * valid function value.
4463 createHandlerFn: function(ctx, fn /*, ... */) {
4464 if (typeof(fn) == 'string')
4467 if (typeof(fn) != 'function')
4470 var arg_offset = arguments.length - 2;
4472 return Function.prototype.bind.apply(function() {
4473 var t = arguments[arg_offset].currentTarget;
4475 t.classList.add('spinning');
4481 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4482 t.classList.remove('spinning');
4485 }, this.varargs(arguments, 2, ctx));
4489 * Load specified view class path and set it up.
4491 * Transforms the given view path into a class name, requires it
4492 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4493 * resulting class instance is a descendant of
4494 * [LuCI.view]{@link LuCI.view}.
4496 * By instantiating the view class, its corresponding contents are
4497 * rendered and included into the view area. Any runtime errors are
4498 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4500 * @param {string} path
4501 * The view path to render.
4503 * @returns {Promise<LuCI.view>}
4504 * Returns a promise resolving to the loaded view instance.
4506 instantiateView: function(path) {
4507 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4509 return L.require(className).then(function(view) {
4510 if (!(view instanceof View))
4511 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4514 }).catch(function(err) {
4515 dom.content(document.querySelector('#view'), null);
4522 AbstractElement: UIElement,
4525 Textfield: UITextfield,
4526 Textarea: UITextarea,
4527 Checkbox: UICheckbox,
4529 Dropdown: UIDropdown,
4530 DynamicList: UIDynamicList,
4531 Combobox: UICombobox,
4532 ComboButton: UIComboButton,
4533 Hiddenfield: UIHiddenfield,
4534 FileUpload: UIFileUpload