81e0b8182008959a958c3f4a3b6d5dd486174c90
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 * Force validation of the current input value.
161 * Usually input validation is automatically triggered by various DOM events
162 * bound to the input widget. In some cases it is required though to manually
163 * trigger validation runs, e.g. when programmatically altering values.
166 * @memberof LuCI.ui.AbstractElement
168 triggerValidation: function() {
169 if (typeof(this.vfunc
) != 'function')
172 var wasValid
= this.isValid();
176 return (wasValid
!= this.isValid());
180 * Dispatch a custom (synthetic) event in response to received events.
182 * Sets up event handlers on the given target DOM node for the given event
183 * names that dispatch a custom event of the given type to the widget root
186 * The primary purpose of this function is to set up a series of custom
187 * uniform standard events such as `widget-update`, `validation-success`,
188 * `validation-failure` etc. which are triggered by various different
189 * widget specific native DOM events.
192 * @memberof LuCI.ui.AbstractElement
193 * @param {Node} targetNode
194 * Specifies the DOM node on which the native event listeners should be
197 * @param {string} synevent
198 * The name of the custom event to dispatch to the widget root DOM node.
200 * @param {string[]} events
201 * The native DOM events for which event handlers should be registered.
203 registerEvents: function(targetNode
, synevent
, events
) {
204 var dispatchFn
= L
.bind(function(ev
) {
205 this.node
.dispatchEvent(new CustomEvent(synevent
, { bubbles
: true }));
208 for (var i
= 0; i
< events
.length
; i
++)
209 targetNode
.addEventListener(events
[i
], dispatchFn
);
213 * Setup listeners for native DOM events that may update the widget value.
215 * Sets up event handlers on the given target DOM node for the given event
216 * names which may cause the input value to update, such as `keyup` or
217 * `onclick` events. In contrast to change events, such update events will
218 * trigger input value validation.
221 * @memberof LuCI.ui.AbstractElement
222 * @param {Node} targetNode
223 * Specifies the DOM node on which the event listeners should be registered.
225 * @param {...string} events
226 * The DOM events for which event handlers should be registered.
228 setUpdateEvents: function(targetNode
/*, ... */) {
229 var datatype
= this.options
.datatype
,
230 optional
= this.options
.hasOwnProperty('optional') ? this.options
.optional
: true,
231 validate
= this.options
.validate
,
232 events
= this.varargs(arguments
, 1);
234 this.registerEvents(targetNode
, 'widget-update', events
);
236 if (!datatype
&& !validate
)
239 this.vfunc
= UI
.prototype.addValidator
.apply(UI
.prototype, [
240 targetNode
, datatype
|| 'string',
244 this.node
.addEventListener('validation-success', L
.bind(function(ev
) {
245 this.validState
= true;
248 this.node
.addEventListener('validation-failure', L
.bind(function(ev
) {
249 this.validState
= false;
254 * Setup listeners for native DOM events that may change the widget value.
256 * Sets up event handlers on the given target DOM node for the given event
257 * names which may cause the input value to change completely, such as
258 * `change` events in a select menu. In contrast to update events, such
259 * change events will not trigger input value validation but they may cause
260 * field dependencies to get re-evaluated and will mark the input widget
264 * @memberof LuCI.ui.AbstractElement
265 * @param {Node} targetNode
266 * Specifies the DOM node on which the event listeners should be registered.
268 * @param {...string} events
269 * The DOM events for which event handlers should be registered.
271 setChangeEvents: function(targetNode
/*, ... */) {
272 var tag_changed
= L
.bind(function(ev
) { this.setAttribute('data-changed', true) }, this.node
);
274 for (var i
= 1; i
< arguments
.length
; i
++)
275 targetNode
.addEventListener(arguments
[i
], tag_changed
);
277 this.registerEvents(targetNode
, 'widget-change', this.varargs(arguments
, 1));
281 * Render the widget, setup event listeners and return resulting markup.
284 * @memberof LuCI.ui.AbstractElement
287 * Returns a DOM Node or DocumentFragment containing the rendered
290 render: function() {}
294 * Instantiate a text input widget.
296 * @constructor Textfield
298 * @augments LuCI.ui.AbstractElement
302 * The `Textfield` class implements a standard single line text input field.
304 * UI widget instances are usually not supposed to be created by view code
305 * directly, instead they're implicitely created by `LuCI.form` when
306 * instantiating CBI forms.
308 * This class is automatically instantiated as part of `LuCI.ui`. To use it
309 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
310 * external JavaScript, use `L.require("ui").then(...)` and access the
311 * `Textfield` property of the class instance value.
313 * @param {string} [value=null]
314 * The initial input value.
316 * @param {LuCI.ui.Textfield.InitOptions} [options]
317 * Object describing the widget specific options to initialize the input.
319 var UITextfield
= UIElement
.extend(/** @lends LuCI.ui.Textfield.prototype */ {
321 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
322 * the following properties are recognized:
324 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
325 * @memberof LuCI.ui.Textfield
327 * @property {boolean} [password=false]
328 * Specifies whether the input should be rendered as concealed password field.
330 * @property {boolean} [readonly=false]
331 * Specifies whether the input widget should be rendered readonly.
333 * @property {number} [maxlength]
334 * Specifies the HTML `maxlength` attribute to set on the corresponding
335 * `<input>` element. Note that this a legacy property that exists for
336 * compatibility reasons. It is usually better to `maxlength(N)` validation
339 * @property {string} [placeholder]
340 * Specifies the HTML `placeholder` attribute which is displayed when the
341 * corresponding `<input>` element is empty.
343 __init__: function(value
, options
) {
345 this.options
= Object
.assign({
353 var frameEl
= E('div', { 'id': this.options
.id
});
354 var inputEl
= E('input', {
355 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
356 'name': this.options
.name
,
358 'class': this.options
.password
? 'cbi-input-password' : 'cbi-input-text',
359 'readonly': this.options
.readonly
? '' : null,
360 'disabled': this.options
.disabled
? '' : null,
361 'maxlength': this.options
.maxlength
,
362 'placeholder': this.options
.placeholder
,
366 if (this.options
.password
) {
367 frameEl
.appendChild(E('div', { 'class': 'control-group' }, [
370 'class': 'cbi-button cbi-button-neutral',
371 'title': _('Reveal/hide password'),
372 'aria-label': _('Reveal/hide password'),
373 'click': function(ev
) {
374 var e
= this.previousElementSibling
;
375 e
.type
= (e
.type
=== 'password') ? 'text' : 'password';
381 window
.requestAnimationFrame(function() { inputEl
.type
= 'password' });
384 frameEl
.appendChild(inputEl
);
387 return this.bind(frameEl
);
391 bind: function(frameEl
) {
392 var inputEl
= frameEl
.querySelector('input');
396 this.setUpdateEvents(inputEl
, 'keyup', 'blur');
397 this.setChangeEvents(inputEl
, 'change');
399 dom
.bindClassInstance(frameEl
, this);
405 getValue: function() {
406 var inputEl
= this.node
.querySelector('input');
407 return inputEl
.value
;
411 setValue: function(value
) {
412 var inputEl
= this.node
.querySelector('input');
413 inputEl
.value
= value
;
418 * Instantiate a textarea widget.
420 * @constructor Textarea
422 * @augments LuCI.ui.AbstractElement
426 * The `Textarea` class implements a multiline text area input field.
428 * UI widget instances are usually not supposed to be created by view code
429 * directly, instead they're implicitely created by `LuCI.form` when
430 * instantiating CBI forms.
432 * This class is automatically instantiated as part of `LuCI.ui`. To use it
433 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
434 * external JavaScript, use `L.require("ui").then(...)` and access the
435 * `Textarea` property of the class instance value.
437 * @param {string} [value=null]
438 * The initial input value.
440 * @param {LuCI.ui.Textarea.InitOptions} [options]
441 * Object describing the widget specific options to initialize the input.
443 var UITextarea
= UIElement
.extend(/** @lends LuCI.ui.Textarea.prototype */ {
445 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
446 * the following properties are recognized:
448 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
449 * @memberof LuCI.ui.Textarea
451 * @property {boolean} [readonly=false]
452 * Specifies whether the input widget should be rendered readonly.
454 * @property {string} [placeholder]
455 * Specifies the HTML `placeholder` attribute which is displayed when the
456 * corresponding `<textarea>` element is empty.
458 * @property {boolean} [monospace=false]
459 * Specifies whether a monospace font should be forced for the textarea
462 * @property {number} [cols]
463 * Specifies the HTML `cols` attribute to set on the corresponding
464 * `<textarea>` element.
466 * @property {number} [rows]
467 * Specifies the HTML `rows` attribute to set on the corresponding
468 * `<textarea>` element.
470 * @property {boolean} [wrap=false]
471 * Specifies whether the HTML `wrap` attribute should be set.
473 __init__: function(value
, options
) {
475 this.options
= Object
.assign({
485 var style
= !this.options
.cols
? 'width:100%' : null,
486 frameEl
= E('div', { 'id': this.options
.id
, 'style': style
}),
487 value
= (this.value
!= null) ? String(this.value
) : '';
489 frameEl
.appendChild(E('textarea', {
490 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
491 'name': this.options
.name
,
492 'class': 'cbi-input-textarea',
493 'readonly': this.options
.readonly
? '' : null,
494 'disabled': this.options
.disabled
? '' : null,
495 'placeholder': this.options
.placeholder
,
497 'cols': this.options
.cols
,
498 'rows': this.options
.rows
,
499 'wrap': this.options
.wrap
? '' : null
502 if (this.options
.monospace
)
503 frameEl
.firstElementChild
.style
.fontFamily
= 'monospace';
505 return this.bind(frameEl
);
509 bind: function(frameEl
) {
510 var inputEl
= frameEl
.firstElementChild
;
514 this.setUpdateEvents(inputEl
, 'keyup', 'blur');
515 this.setChangeEvents(inputEl
, 'change');
517 dom
.bindClassInstance(frameEl
, this);
523 getValue: function() {
524 return this.node
.firstElementChild
.value
;
528 setValue: function(value
) {
529 this.node
.firstElementChild
.value
= value
;
534 * Instantiate a checkbox widget.
536 * @constructor Checkbox
538 * @augments LuCI.ui.AbstractElement
542 * The `Checkbox` class implements a simple checkbox input field.
544 * UI widget instances are usually not supposed to be created by view code
545 * directly, instead they're implicitely created by `LuCI.form` when
546 * instantiating CBI forms.
548 * This class is automatically instantiated as part of `LuCI.ui`. To use it
549 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
550 * external JavaScript, use `L.require("ui").then(...)` and access the
551 * `Checkbox` property of the class instance value.
553 * @param {string} [value=null]
554 * The initial input value.
556 * @param {LuCI.ui.Checkbox.InitOptions} [options]
557 * Object describing the widget specific options to initialize the input.
559 var UICheckbox
= UIElement
.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
561 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
562 * the following properties are recognized:
564 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
565 * @memberof LuCI.ui.Checkbox
567 * @property {string} [value_enabled=1]
568 * Specifies the value corresponding to a checked checkbox.
570 * @property {string} [value_disabled=0]
571 * Specifies the value corresponding to an unchecked checkbox.
573 * @property {string} [hiddenname]
574 * Specifies the HTML `name` attribute of the hidden input backing the
575 * checkbox. This is a legacy property existing for compatibility reasons,
576 * it is required for HTML based form submissions.
578 __init__: function(value
, options
) {
580 this.options
= Object
.assign({
588 var id
= 'cb%08x'.format(Math
.random() * 0xffffffff);
589 var frameEl
= E('div', {
590 'id': this.options
.id
,
591 'class': 'cbi-checkbox'
594 if (this.options
.hiddenname
)
595 frameEl
.appendChild(E('input', {
597 'name': this.options
.hiddenname
,
601 frameEl
.appendChild(E('input', {
603 'name': this.options
.name
,
605 'value': this.options
.value_enabled
,
606 'checked': (this.value
== this.options
.value_enabled
) ? '' : null,
607 'disabled': this.options
.disabled
? '' : null,
608 'data-widget-id': this.options
.id
? 'widget.' + this.options
.id
: null
611 frameEl
.appendChild(E('label', { 'for': id
}));
613 if (this.options
.tooltip
!= null) {
615 E('label', { 'class': 'cbi-tooltip-container' },[
617 E('div', { 'class': 'cbi-tooltip' },
624 return this.bind(frameEl
);
628 bind: function(frameEl
) {
631 this.setUpdateEvents(frameEl
.lastElementChild
.previousElementSibling
, 'click', 'blur');
632 this.setChangeEvents(frameEl
.lastElementChild
.previousElementSibling
, 'change');
634 dom
.bindClassInstance(frameEl
, this);
640 * Test whether the checkbox is currently checked.
643 * @memberof LuCI.ui.Checkbox
645 * Returns `true` when the checkbox is currently checked, otherwise `false`.
647 isChecked: function() {
648 return this.node
.lastElementChild
.previousElementSibling
.checked
;
652 getValue: function() {
653 return this.isChecked()
654 ? this.options
.value_enabled
655 : this.options
.value_disabled
;
659 setValue: function(value
) {
660 this.node
.lastElementChild
.previousElementSibling
.checked
= (value
== this.options
.value_enabled
);
665 * Instantiate a select dropdown or checkbox/radiobutton group.
667 * @constructor Select
669 * @augments LuCI.ui.AbstractElement
673 * The `Select` class implements either a traditional HTML `<select>` element
674 * or a group of checkboxes or radio buttons, depending on whether multiple
675 * values are enabled or not.
677 * UI widget instances are usually not supposed to be created by view code
678 * directly, instead they're implicitely created by `LuCI.form` when
679 * instantiating CBI forms.
681 * This class is automatically instantiated as part of `LuCI.ui`. To use it
682 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
683 * external JavaScript, use `L.require("ui").then(...)` and access the
684 * `Select` property of the class instance value.
686 * @param {string|string[]} [value=null]
687 * The initial input value(s).
689 * @param {Object<string, string>} choices
690 * Object containing the selectable choices of the widget. The object keys
691 * serve as values for the different choices while the values are used as
694 * @param {LuCI.ui.Select.InitOptions} [options]
695 * Object describing the widget specific options to initialize the inputs.
697 var UISelect
= UIElement
.extend(/** @lends LuCI.ui.Select.prototype */ {
699 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
700 * the following properties are recognized:
702 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
703 * @memberof LuCI.ui.Select
705 * @property {boolean} [multiple=false]
706 * Specifies whether multiple choice values may be selected.
708 * @property {string} [widget=select]
709 * Specifies the kind of widget to render. May be either `select` or
710 * `individual`. When set to `select` an HTML `<select>` element will be
711 * used, otherwise a group of checkbox or radio button elements is created,
712 * depending on the value of the `multiple` option.
714 * @property {string} [orientation=horizontal]
715 * Specifies whether checkbox / radio button groups should be rendered
716 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
719 * @property {boolean|string[]} [sort=false]
720 * Specifies if and how to sort choice values. If set to `true`, the choice
721 * values will be sorted alphabetically. If set to an array of strings, the
722 * choice sort order is derived from the array.
724 * @property {number} [size]
725 * Specifies the HTML `size` attribute to set on the `<select>` element.
726 * Only applicable to the `select` widget type.
728 * @property {string} [placeholder=-- Please choose --]
729 * Specifies a placeholder text which is displayed when no choice is
730 * selected yet. Only applicable to the `select` widget type.
732 __init__: function(value
, choices
, options
) {
733 if (!L
.isObject(choices
))
736 if (!Array
.isArray(value
))
737 value
= (value
!= null && value
!= '') ? [ value
] : [];
739 if (!options
.multiple
&& value
.length
> 1)
743 this.choices
= choices
;
744 this.options
= Object
.assign({
747 orientation
: 'horizontal'
750 if (this.choices
.hasOwnProperty(''))
751 this.options
.optional
= true;
756 var frameEl
= E('div', { 'id': this.options
.id
}),
757 keys
= Object
.keys(this.choices
);
759 if (this.options
.sort
=== true)
761 else if (Array
.isArray(this.options
.sort
))
762 keys
= this.options
.sort
;
764 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
765 frameEl
.appendChild(E('select', {
766 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
767 'name': this.options
.name
,
768 'size': this.options
.size
,
769 'class': 'cbi-input-select',
770 'multiple': this.options
.multiple
? '' : null,
771 'disabled': this.options
.disabled
? '' : null
774 if (this.options
.optional
)
775 frameEl
.lastChild
.appendChild(E('option', {
777 'selected': (this.values
.length
== 0 || this.values
[0] == '') ? '' : null
778 }, [ this.choices
[''] || this.options
.placeholder
|| _('-- Please choose --') ]));
780 for (var i
= 0; i
< keys
.length
; i
++) {
781 if (keys
[i
] == null || keys
[i
] == '')
784 frameEl
.lastChild
.appendChild(E('option', {
786 'selected': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null
787 }, [ this.choices
[keys
[i
]] || keys
[i
] ]));
791 var brEl
= (this.options
.orientation
=== 'horizontal') ? document
.createTextNode(' \xa0 ') : E('br');
793 for (var i
= 0; i
< keys
.length
; i
++) {
794 frameEl
.appendChild(E('span', {
795 'class': 'cbi-%s'.format(this.options
.multiple
? 'checkbox' : 'radio')
798 'id': this.options
.id
? 'widget.%s.%d'.format(this.options
.id
, i
) : null,
799 'name': this.options
.id
|| this.options
.name
,
800 'type': this.options
.multiple
? 'checkbox' : 'radio',
801 'class': this.options
.multiple
? 'cbi-input-checkbox' : 'cbi-input-radio',
803 'checked': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null,
804 'disabled': this.options
.disabled
? '' : null
806 E('label', { 'for': this.options
.id
? 'widget.%s.%d'.format(this.options
.id
, i
) : null }),
808 'click': function(ev
) {
809 ev
.currentTarget
.previousElementSibling
.previousElementSibling
.click();
811 }, [ this.choices
[keys
[i
]] || keys
[i
] ])
814 frameEl
.appendChild(brEl
.cloneNode());
818 return this.bind(frameEl
);
822 bind: function(frameEl
) {
825 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
826 this.setUpdateEvents(frameEl
.firstChild
, 'change', 'click', 'blur');
827 this.setChangeEvents(frameEl
.firstChild
, 'change');
830 var radioEls
= frameEl
.querySelectorAll('input[type="radio"]');
831 for (var i
= 0; i
< radioEls
.length
; i
++) {
832 this.setUpdateEvents(radioEls
[i
], 'change', 'click', 'blur');
833 this.setChangeEvents(radioEls
[i
], 'change', 'click', 'blur');
837 dom
.bindClassInstance(frameEl
, this);
843 getValue: function() {
844 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox')
845 return this.node
.firstChild
.value
;
847 var radioEls
= this.node
.querySelectorAll('input[type="radio"]');
848 for (var i
= 0; i
< radioEls
.length
; i
++)
849 if (radioEls
[i
].checked
)
850 return radioEls
[i
].value
;
856 setValue: function(value
) {
857 if (this.options
.widget
!= 'radio' && this.options
.widget
!= 'checkbox') {
861 for (var i
= 0; i
< this.node
.firstChild
.options
.length
; i
++)
862 this.node
.firstChild
.options
[i
].selected
= (this.node
.firstChild
.options
[i
].value
== value
);
867 var radioEls
= frameEl
.querySelectorAll('input[type="radio"]');
868 for (var i
= 0; i
< radioEls
.length
; i
++)
869 radioEls
[i
].checked
= (radioEls
[i
].value
== value
);
874 * Instantiate a rich dropdown choice widget.
876 * @constructor Dropdown
878 * @augments LuCI.ui.AbstractElement
882 * The `Dropdown` class implements a rich, stylable dropdown menu which
883 * supports non-text choice labels.
885 * UI widget instances are usually not supposed to be created by view code
886 * directly, instead they're implicitely created by `LuCI.form` when
887 * instantiating CBI forms.
889 * This class is automatically instantiated as part of `LuCI.ui`. To use it
890 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
891 * external JavaScript, use `L.require("ui").then(...)` and access the
892 * `Dropdown` property of the class instance value.
894 * @param {string|string[]} [value=null]
895 * The initial input value(s).
897 * @param {Object<string, *>} choices
898 * Object containing the selectable choices of the widget. The object keys
899 * serve as values for the different choices while the values are used as
902 * @param {LuCI.ui.Dropdown.InitOptions} [options]
903 * Object describing the widget specific options to initialize the dropdown.
905 var UIDropdown
= UIElement
.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
907 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
908 * the following properties are recognized:
910 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
911 * @memberof LuCI.ui.Dropdown
913 * @property {boolean} [optional=true]
914 * Specifies whether the dropdown selection is optional. In contrast to
915 * other widgets, the `optional` constraint of dropdowns works differently;
916 * instead of marking the widget invalid on empty values when set to `false`,
917 * the user is not allowed to deselect all choices.
919 * For single value dropdowns that means that no empty "please select"
920 * choice is offered and for multi value dropdowns, the last selected choice
921 * may not be deselected without selecting another choice first.
923 * @property {boolean} [multiple]
924 * Specifies whether multiple choice values may be selected. It defaults
925 * to `true` when an array is passed as input value to the constructor.
927 * @property {boolean|string[]} [sort=false]
928 * Specifies if and how to sort choice values. If set to `true`, the choice
929 * values will be sorted alphabetically. If set to an array of strings, the
930 * choice sort order is derived from the array.
932 * @property {string} [select_placeholder=-- Please choose --]
933 * Specifies a placeholder text which is displayed when no choice is
936 * @property {string} [custom_placeholder=-- custom --]
937 * Specifies a placeholder text which is displayed in the text input
938 * field allowing to enter custom choice values. Only applicable if the
939 * `create` option is set to `true`.
941 * @property {boolean} [create=false]
942 * Specifies whether custom choices may be entered into the dropdown
945 * @property {string} [create_query=.create-item-input]
946 * Specifies a CSS selector expression used to find the input element
947 * which is used to enter custom choice values. This should not normally
948 * be used except by widgets derived from the Dropdown class.
950 * @property {string} [create_template=script[type="item-template"]]
951 * Specifies a CSS selector expression used to find an HTML element
952 * serving as template for newly added custom choice values.
954 * Any `{{value}}` placeholder string within the template elements text
955 * content will be replaced by the user supplied choice value, the
956 * resulting string is parsed as HTML and appended to the end of the
957 * choice list. The template markup may specify one HTML element with a
958 * `data-label-placeholder` attribute which is replaced by a matching
959 * label value from the `choices` object or with the user supplied value
960 * itself in case `choices` contains no matching choice label.
962 * If the template element is not found or if no `create_template` selector
963 * expression is specified, the default markup for newly created elements is
964 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
966 * @property {string} [create_markup]
967 * This property allows specifying the markup for custom choices directly
968 * instead of referring to a template element through CSS selectors.
970 * Apart from that it works exactly like `create_template`.
972 * @property {number} [display_items=3]
973 * Specifies the maximum amount of choice labels that should be shown in
974 * collapsed dropdown state before further selected choices are cut off.
976 * Only applicable when `multiple` is `true`.
978 * @property {number} [dropdown_items=-1]
979 * Specifies the maximum amount of choices that should be shown when the
980 * dropdown is open. If the amount of available choices exceeds this number,
981 * the dropdown area must be scrolled to reach further items.
983 * If set to `-1`, the dropdown menu will attempt to show all choice values
984 * and only resort to scrolling if the amount of choices exceeds the available
985 * screen space above and below the dropdown widget.
987 * @property {string} [placeholder]
988 * This property serves as a shortcut to set both `select_placeholder` and
989 * `custom_placeholder`. Either of these properties will fallback to
990 * `placeholder` if not specified.
992 * @property {boolean} [readonly=false]
993 * Specifies whether the custom choice input field should be rendered
994 * readonly. Only applicable when `create` is `true`.
996 * @property {number} [maxlength]
997 * Specifies the HTML `maxlength` attribute to set on the custom choice
998 * `<input>` element. Note that this a legacy property that exists for
999 * compatibility reasons. It is usually better to `maxlength(N)` validation
1000 * expression. Only applicable when `create` is `true`.
1002 __init__: function(value
, choices
, options
) {
1003 if (typeof(choices
) != 'object')
1006 if (!Array
.isArray(value
))
1007 this.values
= (value
!= null && value
!= '') ? [ value
] : [];
1009 this.values
= value
;
1011 this.choices
= choices
;
1012 this.options
= Object
.assign({
1014 multiple
: Array
.isArray(value
),
1016 select_placeholder
: _('-- Please choose --'),
1017 custom_placeholder
: _('-- custom --'),
1021 create_query
: '.create-item-input',
1022 create_template
: 'script[type="item-template"]'
1027 render: function() {
1029 'id': this.options
.id
,
1030 'class': 'cbi-dropdown',
1031 'multiple': this.options
.multiple
? '' : null,
1032 'optional': this.options
.optional
? '' : null,
1033 'disabled': this.options
.disabled
? '' : null
1036 var keys
= Object
.keys(this.choices
);
1038 if (this.options
.sort
=== true)
1040 else if (Array
.isArray(this.options
.sort
))
1041 keys
= this.options
.sort
;
1043 if (this.options
.create
)
1044 for (var i
= 0; i
< this.values
.length
; i
++)
1045 if (!this.choices
.hasOwnProperty(this.values
[i
]))
1046 keys
.push(this.values
[i
]);
1048 for (var i
= 0; i
< keys
.length
; i
++) {
1049 var label
= this.choices
[keys
[i
]];
1051 if (dom
.elem(label
))
1052 label
= label
.cloneNode(true);
1054 sb
.lastElementChild
.appendChild(E('li', {
1055 'data-value': keys
[i
],
1056 'selected': (this.values
.indexOf(keys
[i
]) > -1) ? '' : null
1057 }, [ label
|| keys
[i
] ]));
1060 if (this.options
.create
) {
1061 var createEl
= E('input', {
1063 'class': 'create-item-input',
1064 'readonly': this.options
.readonly
? '' : null,
1065 'maxlength': this.options
.maxlength
,
1066 'placeholder': this.options
.custom_placeholder
|| this.options
.placeholder
1069 if (this.options
.datatype
|| this.options
.validate
)
1070 UI
.prototype.addValidator(createEl
, this.options
.datatype
|| 'string',
1071 true, this.options
.validate
, 'blur', 'keyup');
1073 sb
.lastElementChild
.appendChild(E('li', { 'data-value': '-' }, createEl
));
1076 if (this.options
.create_markup
)
1077 sb
.appendChild(E('script', { type
: 'item-template' },
1078 this.options
.create_markup
));
1080 return this.bind(sb
);
1084 bind: function(sb
) {
1085 var o
= this.options
;
1087 o
.multiple
= sb
.hasAttribute('multiple');
1088 o
.optional
= sb
.hasAttribute('optional');
1089 o
.placeholder
= sb
.getAttribute('placeholder') || o
.placeholder
;
1090 o
.display_items
= parseInt(sb
.getAttribute('display-items') || o
.display_items
);
1091 o
.dropdown_items
= parseInt(sb
.getAttribute('dropdown-items') || o
.dropdown_items
);
1092 o
.create_query
= sb
.getAttribute('item-create') || o
.create_query
;
1093 o
.create_template
= sb
.getAttribute('item-template') || o
.create_template
;
1095 var ul
= sb
.querySelector('ul'),
1096 more
= sb
.appendChild(E('span', { class: 'more', tabindex
: -1 }, '···')),
1097 open
= sb
.appendChild(E('span', { class: 'open', tabindex
: -1 }, '▾')),
1098 canary
= sb
.appendChild(E('div')),
1099 create
= sb
.querySelector(this.options
.create_query
),
1100 ndisplay
= this.options
.display_items
,
1103 if (this.options
.multiple
) {
1104 var items
= ul
.querySelectorAll('li');
1106 for (var i
= 0; i
< items
.length
; i
++) {
1107 this.transformItem(sb
, items
[i
]);
1109 if (items
[i
].hasAttribute('selected') && ndisplay
-- > 0)
1110 items
[i
].setAttribute('display', n
++);
1114 if (this.options
.optional
&& !ul
.querySelector('li[data-value=""]')) {
1115 var placeholder
= E('li', { placeholder
: '' },
1116 this.options
.select_placeholder
|| this.options
.placeholder
);
1119 ? ul
.insertBefore(placeholder
, ul
.firstChild
)
1120 : ul
.appendChild(placeholder
);
1123 var items
= ul
.querySelectorAll('li'),
1124 sel
= sb
.querySelectorAll('[selected]');
1126 sel
.forEach(function(s
) {
1127 s
.removeAttribute('selected');
1130 var s
= sel
[0] || items
[0];
1132 s
.setAttribute('selected', '');
1133 s
.setAttribute('display', n
++);
1139 this.saveValues(sb
, ul
);
1141 ul
.setAttribute('tabindex', -1);
1142 sb
.setAttribute('tabindex', 0);
1145 sb
.setAttribute('more', '')
1147 sb
.removeAttribute('more');
1149 if (ndisplay
== this.options
.display_items
)
1150 sb
.setAttribute('empty', '')
1152 sb
.removeAttribute('empty');
1154 dom
.content(more
, (ndisplay
== this.options
.display_items
)
1155 ? (this.options
.select_placeholder
|| this.options
.placeholder
) : '···');
1158 sb
.addEventListener('click', this.handleClick
.bind(this));
1159 sb
.addEventListener('keydown', this.handleKeydown
.bind(this));
1160 sb
.addEventListener('cbi-dropdown-close', this.handleDropdownClose
.bind(this));
1161 sb
.addEventListener('cbi-dropdown-select', this.handleDropdownSelect
.bind(this));
1163 if ('ontouchstart' in window
) {
1164 sb
.addEventListener('touchstart', function(ev
) { ev
.stopPropagation(); });
1165 window
.addEventListener('touchstart', this.closeAllDropdowns
);
1168 sb
.addEventListener('mouseover', this.handleMouseover
.bind(this));
1169 sb
.addEventListener('focus', this.handleFocus
.bind(this));
1171 canary
.addEventListener('focus', this.handleCanaryFocus
.bind(this));
1173 window
.addEventListener('mouseover', this.setFocus
);
1174 window
.addEventListener('click', this.closeAllDropdowns
);
1178 create
.addEventListener('keydown', this.handleCreateKeydown
.bind(this));
1179 create
.addEventListener('focus', this.handleCreateFocus
.bind(this));
1180 create
.addEventListener('blur', this.handleCreateBlur
.bind(this));
1182 var li
= findParent(create
, 'li');
1184 li
.setAttribute('unselectable', '');
1185 li
.addEventListener('click', this.handleCreateClick
.bind(this));
1190 this.setUpdateEvents(sb
, 'cbi-dropdown-open', 'cbi-dropdown-close');
1191 this.setChangeEvents(sb
, 'cbi-dropdown-change', 'cbi-dropdown-close');
1193 dom
.bindClassInstance(sb
, this);
1199 openDropdown: function(sb
) {
1200 var st
= window
.getComputedStyle(sb
, null),
1201 ul
= sb
.querySelector('ul'),
1202 li
= ul
.querySelectorAll('li'),
1203 fl
= findParent(sb
, '.cbi-value-field'),
1204 sel
= ul
.querySelector('[selected]'),
1205 rect
= sb
.getBoundingClientRect(),
1206 items
= Math
.min(this.options
.dropdown_items
, li
.length
);
1208 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1209 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1212 sb
.setAttribute('open', '');
1214 var pv
= ul
.cloneNode(true);
1215 pv
.classList
.add('preview');
1218 fl
.classList
.add('cbi-dropdown-open');
1220 if ('ontouchstart' in window
) {
1221 var vpWidth
= Math
.max(document
.documentElement
.clientWidth
, window
.innerWidth
|| 0),
1222 vpHeight
= Math
.max(document
.documentElement
.clientHeight
, window
.innerHeight
|| 0),
1225 ul
.style
.top
= sb
.offsetHeight
+ 'px';
1226 ul
.style
.left
= -rect
.left
+ 'px';
1227 ul
.style
.right
= (rect
.right
- vpWidth
) + 'px';
1228 ul
.style
.maxHeight
= (vpHeight
* 0.5) + 'px';
1229 ul
.style
.WebkitOverflowScrolling
= 'touch';
1231 var getScrollParent = function(element
) {
1232 var parent
= element
,
1233 style
= getComputedStyle(element
),
1234 excludeStaticParent
= (style
.position
=== 'absolute');
1236 if (style
.position
=== 'fixed')
1237 return document
.body
;
1239 while ((parent
= parent
.parentElement
) != null) {
1240 style
= getComputedStyle(parent
);
1242 if (excludeStaticParent
&& style
.position
=== 'static')
1245 if (/(auto|scroll)/.test(style
.overflow
+ style
.overflowY
+ style
.overflowX
))
1249 return document
.body
;
1252 var scrollParent
= getScrollParent(sb
),
1253 scrollFrom
= scrollParent
.scrollTop
,
1254 scrollTo
= scrollFrom
+ rect
.top
- vpHeight
* 0.5;
1256 var scrollStep = function(timestamp
) {
1259 ul
.scrollTop
= sel
? Math
.max(sel
.offsetTop
- sel
.offsetHeight
, 0) : 0;
1262 var duration
= Math
.max(timestamp
- start
, 1);
1263 if (duration
< 100) {
1264 scrollParent
.scrollTop
= scrollFrom
+ (scrollTo
- scrollFrom
) * (duration
/ 100);
1265 window
.requestAnimationFrame(scrollStep
);
1268 scrollParent
.scrollTop
= scrollTo
;
1272 window
.requestAnimationFrame(scrollStep
);
1275 ul
.style
.maxHeight
= '1px';
1276 ul
.style
.top
= ul
.style
.bottom
= '';
1278 window
.requestAnimationFrame(function() {
1279 var itemHeight
= li
[Math
.max(0, li
.length
- 2)].getBoundingClientRect().height
,
1281 spaceAbove
= rect
.top
,
1282 spaceBelow
= window
.innerHeight
- rect
.height
- rect
.top
;
1284 for (var i
= 0; i
< (items
== -1 ? li
.length
: items
); i
++)
1285 fullHeight
+= li
[i
].getBoundingClientRect().height
;
1287 if (fullHeight
<= spaceBelow
) {
1288 ul
.style
.top
= rect
.height
+ 'px';
1289 ul
.style
.maxHeight
= spaceBelow
+ 'px';
1291 else if (fullHeight
<= spaceAbove
) {
1292 ul
.style
.bottom
= rect
.height
+ 'px';
1293 ul
.style
.maxHeight
= spaceAbove
+ 'px';
1295 else if (spaceBelow
>= spaceAbove
) {
1296 ul
.style
.top
= rect
.height
+ 'px';
1297 ul
.style
.maxHeight
= (spaceBelow
- (spaceBelow
% itemHeight
)) + 'px';
1300 ul
.style
.bottom
= rect
.height
+ 'px';
1301 ul
.style
.maxHeight
= (spaceAbove
- (spaceAbove
% itemHeight
)) + 'px';
1304 ul
.scrollTop
= sel
? Math
.max(sel
.offsetTop
- sel
.offsetHeight
, 0) : 0;
1308 var cboxes
= ul
.querySelectorAll('[selected] input[type="checkbox"]');
1309 for (var i
= 0; i
< cboxes
.length
; i
++) {
1310 cboxes
[i
].checked
= true;
1311 cboxes
[i
].disabled
= (cboxes
.length
== 1 && !this.options
.optional
);
1314 ul
.classList
.add('dropdown');
1316 sb
.insertBefore(pv
, ul
.nextElementSibling
);
1318 li
.forEach(function(l
) {
1319 l
.setAttribute('tabindex', 0);
1322 sb
.lastElementChild
.setAttribute('tabindex', 0);
1324 this.setFocus(sb
, sel
|| li
[0], true);
1328 closeDropdown: function(sb
, no_focus
) {
1329 if (!sb
.hasAttribute('open'))
1332 var pv
= sb
.querySelector('ul.preview'),
1333 ul
= sb
.querySelector('ul.dropdown'),
1334 li
= ul
.querySelectorAll('li'),
1335 fl
= findParent(sb
, '.cbi-value-field');
1337 li
.forEach(function(l
) { l
.removeAttribute('tabindex'); });
1338 sb
.lastElementChild
.removeAttribute('tabindex');
1341 sb
.removeAttribute('open');
1342 sb
.style
.width
= sb
.style
.height
= '';
1344 ul
.classList
.remove('dropdown');
1345 ul
.style
.top
= ul
.style
.bottom
= ul
.style
.maxHeight
= '';
1348 fl
.classList
.remove('cbi-dropdown-open');
1351 this.setFocus(sb
, sb
);
1353 this.saveValues(sb
, ul
);
1357 toggleItem: function(sb
, li
, force_state
) {
1358 if (li
.hasAttribute('unselectable'))
1361 if (this.options
.multiple
) {
1362 var cbox
= li
.querySelector('input[type="checkbox"]'),
1363 items
= li
.parentNode
.querySelectorAll('li'),
1364 label
= sb
.querySelector('ul.preview'),
1365 sel
= li
.parentNode
.querySelectorAll('[selected]').length
,
1366 more
= sb
.querySelector('.more'),
1367 ndisplay
= this.options
.display_items
,
1370 if (li
.hasAttribute('selected')) {
1371 if (force_state
!== true) {
1372 if (sel
> 1 || this.options
.optional
) {
1373 li
.removeAttribute('selected');
1374 cbox
.checked
= cbox
.disabled
= false;
1378 cbox
.disabled
= true;
1383 if (force_state
!== false) {
1384 li
.setAttribute('selected', '');
1385 cbox
.checked
= true;
1386 cbox
.disabled
= false;
1391 while (label
&& label
.firstElementChild
)
1392 label
.removeChild(label
.firstElementChild
);
1394 for (var i
= 0; i
< items
.length
; i
++) {
1395 items
[i
].removeAttribute('display');
1396 if (items
[i
].hasAttribute('selected')) {
1397 if (ndisplay
-- > 0) {
1398 items
[i
].setAttribute('display', n
++);
1400 label
.appendChild(items
[i
].cloneNode(true));
1402 var c
= items
[i
].querySelector('input[type="checkbox"]');
1404 c
.disabled
= (sel
== 1 && !this.options
.optional
);
1409 sb
.setAttribute('more', '');
1411 sb
.removeAttribute('more');
1413 if (ndisplay
=== this.options
.display_items
)
1414 sb
.setAttribute('empty', '');
1416 sb
.removeAttribute('empty');
1418 dom
.content(more
, (ndisplay
=== this.options
.display_items
)
1419 ? (this.options
.select_placeholder
|| this.options
.placeholder
) : '···');
1422 var sel
= li
.parentNode
.querySelector('[selected]');
1424 sel
.removeAttribute('display');
1425 sel
.removeAttribute('selected');
1428 li
.setAttribute('display', 0);
1429 li
.setAttribute('selected', '');
1431 this.closeDropdown(sb
, true);
1434 this.saveValues(sb
, li
.parentNode
);
1438 transformItem: function(sb
, li
) {
1439 var cbox
= E('form', {}, E('input', { type
: 'checkbox', tabindex
: -1, onclick
: 'event.preventDefault()' })),
1442 while (li
.firstChild
)
1443 label
.appendChild(li
.firstChild
);
1445 li
.appendChild(cbox
);
1446 li
.appendChild(label
);
1450 saveValues: function(sb
, ul
) {
1451 var sel
= ul
.querySelectorAll('li[selected]'),
1452 div
= sb
.lastElementChild
,
1453 name
= this.options
.name
,
1457 while (div
.lastElementChild
)
1458 div
.removeChild(div
.lastElementChild
);
1460 sel
.forEach(function (s
) {
1461 if (s
.hasAttribute('placeholder'))
1466 value
: s
.hasAttribute('data-value') ? s
.getAttribute('data-value') : s
.innerText
,
1470 div
.appendChild(E('input', {
1478 strval
+= strval
.length
? ' ' + v
.value
: v
.value
;
1486 if (this.options
.multiple
)
1487 detail
.values
= values
;
1489 detail
.value
= values
.length
? values
[0] : null;
1493 sb
.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1500 setValues: function(sb
, values
) {
1501 var ul
= sb
.querySelector('ul');
1503 if (this.options
.create
) {
1504 for (var value
in values
) {
1505 this.createItems(sb
, value
);
1507 if (!this.options
.multiple
)
1512 if (this.options
.multiple
) {
1513 var lis
= ul
.querySelectorAll('li[data-value]');
1514 for (var i
= 0; i
< lis
.length
; i
++) {
1515 var value
= lis
[i
].getAttribute('data-value');
1516 if (values
=== null || !(value
in values
))
1517 this.toggleItem(sb
, lis
[i
], false);
1519 this.toggleItem(sb
, lis
[i
], true);
1523 var ph
= ul
.querySelector('li[placeholder]');
1525 this.toggleItem(sb
, ph
);
1527 var lis
= ul
.querySelectorAll('li[data-value]');
1528 for (var i
= 0; i
< lis
.length
; i
++) {
1529 var value
= lis
[i
].getAttribute('data-value');
1530 if (values
!== null && (value
in values
))
1531 this.toggleItem(sb
, lis
[i
]);
1537 setFocus: function(sb
, elem
, scroll
) {
1538 if (sb
&& sb
.hasAttribute
&& sb
.hasAttribute('locked-in'))
1541 if (sb
.target
&& findParent(sb
.target
, 'ul.dropdown'))
1544 document
.querySelectorAll('.focus').forEach(function(e
) {
1545 if (!matchesElem(e
, 'input')) {
1546 e
.classList
.remove('focus');
1553 elem
.classList
.add('focus');
1556 elem
.parentNode
.scrollTop
= elem
.offsetTop
- elem
.parentNode
.offsetTop
;
1561 createChoiceElement: function(sb
, value
, label
) {
1562 var tpl
= sb
.querySelector(this.options
.create_template
),
1566 markup
= (tpl
.textContent
|| tpl
.innerHTML
|| tpl
.firstChild
.data
).replace(/^<!--|-->$/, '').trim();
1568 markup
= '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1570 var new_item
= E(markup
.replace(/{{value}}/g, '%h'.format(value
))),
1571 placeholder
= new_item
.querySelector('[data-label-placeholder]');
1574 var content
= E('span', {}, label
|| this.choices
[value
] || [ value
]);
1576 while (content
.firstChild
)
1577 placeholder
.parentNode
.insertBefore(content
.firstChild
, placeholder
);
1579 placeholder
.parentNode
.removeChild(placeholder
);
1582 if (this.options
.multiple
)
1583 this.transformItem(sb
, new_item
);
1589 createItems: function(sb
, value
) {
1591 val
= (value
|| '').trim(),
1592 ul
= sb
.querySelector('ul');
1594 if (!sbox
.options
.multiple
)
1595 val
= val
.length
? [ val
] : [];
1597 val
= val
.length
? val
.split(/\s+/) : [];
1599 val
.forEach(function(item
) {
1600 var new_item
= null;
1602 ul
.childNodes
.forEach(function(li
) {
1603 if (li
.getAttribute
&& li
.getAttribute('data-value') === item
)
1608 new_item
= sbox
.createChoiceElement(sb
, item
);
1610 if (!sbox
.options
.multiple
) {
1611 var old
= ul
.querySelector('li[created]');
1613 ul
.removeChild(old
);
1615 new_item
.setAttribute('created', '');
1618 new_item
= ul
.insertBefore(new_item
, ul
.lastElementChild
);
1621 sbox
.toggleItem(sb
, new_item
, true);
1622 sbox
.setFocus(sb
, new_item
, true);
1627 * Remove all existing choices from the dropdown menu.
1629 * This function removes all preexisting dropdown choices from the widget,
1630 * keeping only choices currently being selected unless `reset_values` is
1631 * given, in which case all choices and deselected and removed.
1634 * @memberof LuCI.ui.Dropdown
1635 * @param {boolean} [reset_value=false]
1636 * If set to `true`, deselect and remove selected choices as well instead
1639 clearChoices: function(reset_value
) {
1640 var ul
= this.node
.querySelector('ul'),
1641 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [],
1642 len
= lis
.length
- (this.options
.create
? 1 : 0),
1643 val
= reset_value
? null : this.getValue();
1645 for (var i
= 0; i
< len
; i
++) {
1646 var lival
= lis
[i
].getAttribute('data-value');
1648 (!this.options
.multiple
&& val
!= lival
) ||
1649 (this.options
.multiple
&& val
.indexOf(lival
) == -1))
1650 ul
.removeChild(lis
[i
]);
1654 this.setValues(this.node
, {});
1658 * Add new choices to the dropdown menu.
1660 * This function adds further choices to an existing dropdown menu,
1661 * ignoring choice values which are already present.
1664 * @memberof LuCI.ui.Dropdown
1665 * @param {string[]} values
1666 * The choice values to add to the dropdown widget.
1668 * @param {Object<string, *>} labels
1669 * The choice label values to use when adding dropdown choices. If no
1670 * label is found for a particular choice value, the value itself is used
1671 * as label text. Choice labels may be any valid value accepted by
1672 * {@link LuCI.dom#content}.
1674 addChoices: function(values
, labels
) {
1676 ul
= sb
.querySelector('ul'),
1677 lis
= ul
? ul
.querySelectorAll('li[data-value]') : [];
1679 if (!Array
.isArray(values
))
1680 values
= L
.toArray(values
);
1682 if (!L
.isObject(labels
))
1685 for (var i
= 0; i
< values
.length
; i
++) {
1688 for (var j
= 0; j
< lis
.length
; j
++) {
1689 if (lis
[j
].getAttribute('data-value') === values
[i
]) {
1699 this.createChoiceElement(sb
, values
[i
], labels
[values
[i
]]),
1700 ul
.lastElementChild
);
1705 * Close all open dropdown widgets in the current document.
1707 closeAllDropdowns: function() {
1708 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1709 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1714 handleClick: function(ev
) {
1715 var sb
= ev
.currentTarget
;
1717 if (!sb
.hasAttribute('open')) {
1718 if (!matchesElem(ev
.target
, 'input'))
1719 this.openDropdown(sb
);
1722 var li
= findParent(ev
.target
, 'li');
1723 if (li
&& li
.parentNode
.classList
.contains('dropdown'))
1724 this.toggleItem(sb
, li
);
1725 else if (li
&& li
.parentNode
.classList
.contains('preview'))
1726 this.closeDropdown(sb
);
1727 else if (matchesElem(ev
.target
, 'span.open, span.more'))
1728 this.closeDropdown(sb
);
1731 ev
.preventDefault();
1732 ev
.stopPropagation();
1736 handleKeydown: function(ev
) {
1737 var sb
= ev
.currentTarget
;
1739 if (matchesElem(ev
.target
, 'input'))
1742 if (!sb
.hasAttribute('open')) {
1743 switch (ev
.keyCode
) {
1748 this.openDropdown(sb
);
1749 ev
.preventDefault();
1753 var active
= findParent(document
.activeElement
, 'li');
1755 switch (ev
.keyCode
) {
1757 this.closeDropdown(sb
);
1762 if (!active
.hasAttribute('selected'))
1763 this.toggleItem(sb
, active
);
1764 this.closeDropdown(sb
);
1765 ev
.preventDefault();
1771 this.toggleItem(sb
, active
);
1772 ev
.preventDefault();
1777 if (active
&& active
.previousElementSibling
) {
1778 this.setFocus(sb
, active
.previousElementSibling
);
1779 ev
.preventDefault();
1784 if (active
&& active
.nextElementSibling
) {
1785 this.setFocus(sb
, active
.nextElementSibling
);
1786 ev
.preventDefault();
1794 handleDropdownClose: function(ev
) {
1795 var sb
= ev
.currentTarget
;
1797 this.closeDropdown(sb
, true);
1801 handleDropdownSelect: function(ev
) {
1802 var sb
= ev
.currentTarget
,
1803 li
= findParent(ev
.target
, 'li');
1808 this.toggleItem(sb
, li
);
1809 this.closeDropdown(sb
, true);
1813 handleMouseover: function(ev
) {
1814 var sb
= ev
.currentTarget
;
1816 if (!sb
.hasAttribute('open'))
1819 var li
= findParent(ev
.target
, 'li');
1821 if (li
&& li
.parentNode
.classList
.contains('dropdown'))
1822 this.setFocus(sb
, li
);
1826 handleFocus: function(ev
) {
1827 var sb
= ev
.currentTarget
;
1829 document
.querySelectorAll('.cbi-dropdown[open]').forEach(function(s
) {
1830 if (s
!== sb
|| sb
.hasAttribute('open'))
1831 s
.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1836 handleCanaryFocus: function(ev
) {
1837 this.closeDropdown(ev
.currentTarget
.parentNode
);
1841 handleCreateKeydown: function(ev
) {
1842 var input
= ev
.currentTarget
,
1843 sb
= findParent(input
, '.cbi-dropdown');
1845 switch (ev
.keyCode
) {
1847 ev
.preventDefault();
1849 if (input
.classList
.contains('cbi-input-invalid'))
1852 this.createItems(sb
, input
.value
);
1860 handleCreateFocus: function(ev
) {
1861 var input
= ev
.currentTarget
,
1862 cbox
= findParent(input
, 'li').querySelector('input[type="checkbox"]'),
1863 sb
= findParent(input
, '.cbi-dropdown');
1866 cbox
.checked
= true;
1868 sb
.setAttribute('locked-in', '');
1872 handleCreateBlur: function(ev
) {
1873 var input
= ev
.currentTarget
,
1874 cbox
= findParent(input
, 'li').querySelector('input[type="checkbox"]'),
1875 sb
= findParent(input
, '.cbi-dropdown');
1878 cbox
.checked
= false;
1880 sb
.removeAttribute('locked-in');
1884 handleCreateClick: function(ev
) {
1885 ev
.currentTarget
.querySelector(this.options
.create_query
).focus();
1889 setValue: function(values
) {
1890 if (this.options
.multiple
) {
1891 if (!Array
.isArray(values
))
1892 values
= (values
!= null && values
!= '') ? [ values
] : [];
1896 for (var i
= 0; i
< values
.length
; i
++)
1897 v
[values
[i
]] = true;
1899 this.setValues(this.node
, v
);
1904 if (values
!= null) {
1905 if (Array
.isArray(values
))
1906 v
[values
[0]] = true;
1911 this.setValues(this.node
, v
);
1916 getValue: function() {
1917 var div
= this.node
.lastElementChild
,
1918 h
= div
.querySelectorAll('input[type="hidden"]'),
1921 for (var i
= 0; i
< h
.length
; i
++)
1924 return this.options
.multiple
? v
: v
[0];
1929 * Instantiate a rich dropdown choice widget allowing custom values.
1931 * @constructor Combobox
1933 * @augments LuCI.ui.Dropdown
1937 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1938 * to enter custom values. Historically, comboboxes used to be a dedicated
1939 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1940 * with a set of enforced default properties for easier instantiation.
1942 * UI widget instances are usually not supposed to be created by view code
1943 * directly, instead they're implicitely created by `LuCI.form` when
1944 * instantiating CBI forms.
1946 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1947 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1948 * external JavaScript, use `L.require("ui").then(...)` and access the
1949 * `Combobox` property of the class instance value.
1951 * @param {string|string[]} [value=null]
1952 * The initial input value(s).
1954 * @param {Object<string, *>} choices
1955 * Object containing the selectable choices of the widget. The object keys
1956 * serve as values for the different choices while the values are used as
1959 * @param {LuCI.ui.Combobox.InitOptions} [options]
1960 * Object describing the widget specific options to initialize the dropdown.
1962 var UICombobox
= UIDropdown
.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1964 * Comboboxes support the same properties as
1965 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1966 * specific values for the following properties:
1968 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1969 * @memberof LuCI.ui.Combobox
1971 * @property {boolean} multiple=false
1972 * Since Comboboxes never allow selecting multiple values, this property
1973 * is forcibly set to `false`.
1975 * @property {boolean} create=true
1976 * Since Comboboxes always allow custom choice values, this property is
1977 * forcibly set to `true`.
1979 * @property {boolean} optional=true
1980 * Since Comboboxes are always optional, this property is forcibly set to
1983 __init__: function(value
, choices
, options
) {
1984 this.super('__init__', [ value
, choices
, Object
.assign({
1985 select_placeholder
: _('-- Please choose --'),
1986 custom_placeholder
: _('-- custom --'),
1998 * Instantiate a combo button widget offering multiple action choices.
2000 * @constructor ComboButton
2002 * @augments LuCI.ui.Dropdown
2006 * The `ComboButton` class implements a button element which can be expanded
2007 * into a dropdown to chose from a set of different action choices.
2009 * UI widget instances are usually not supposed to be created by view code
2010 * directly, instead they're implicitely created by `LuCI.form` when
2011 * instantiating CBI forms.
2013 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2014 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
2015 * external JavaScript, use `L.require("ui").then(...)` and access the
2016 * `ComboButton` property of the class instance value.
2018 * @param {string|string[]} [value=null]
2019 * The initial input value(s).
2021 * @param {Object<string, *>} choices
2022 * Object containing the selectable choices of the widget. The object keys
2023 * serve as values for the different choices while the values are used as
2026 * @param {LuCI.ui.ComboButton.InitOptions} [options]
2027 * Object describing the widget specific options to initialize the button.
2029 var UIComboButton
= UIDropdown
.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
2031 * ComboButtons support the same properties as
2032 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2033 * specific values for some properties and add aditional button specific
2036 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2037 * @memberof LuCI.ui.ComboButton
2039 * @property {boolean} multiple=false
2040 * Since ComboButtons never allow selecting multiple actions, this property
2041 * is forcibly set to `false`.
2043 * @property {boolean} create=false
2044 * Since ComboButtons never allow creating custom choices, this property
2045 * is forcibly set to `false`.
2047 * @property {boolean} optional=false
2048 * Since ComboButtons must always select one action, this property is
2049 * forcibly set to `false`.
2051 * @property {Object<string, string>} [classes]
2052 * Specifies a mapping of choice values to CSS class names. If an action
2053 * choice is selected by the user and if a corresponding entry exists in
2054 * the `classes` object, the class names corresponding to the selected
2055 * value are set on the button element.
2057 * This is useful to apply different button styles, such as colors, to the
2058 * combined button depending on the selected action.
2060 * @property {function} [click]
2061 * Specifies a handler function to invoke when the user clicks the button.
2062 * This function will be called with the button DOM node as `this` context
2063 * and receive the DOM click event as first as well as the selected action
2064 * choice value as second argument.
2066 __init__: function(value
, choices
, options
) {
2067 this.super('__init__', [ value
, choices
, Object
.assign({
2077 render: function(/* ... */) {
2078 var node
= UIDropdown
.prototype.render
.apply(this, arguments
),
2079 val
= this.getValue();
2081 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2082 node
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2088 handleClick: function(ev
) {
2089 var sb
= ev
.currentTarget
,
2092 if (sb
.hasAttribute('open') || dom
.matches(t
, '.cbi-dropdown > span.open'))
2093 return UIDropdown
.prototype.handleClick
.apply(this, arguments
);
2095 if (this.options
.click
)
2096 return this.options
.click
.call(sb
, ev
, this.getValue());
2100 toggleItem: function(sb
/*, ... */) {
2101 var rv
= UIDropdown
.prototype.toggleItem
.apply(this, arguments
),
2102 val
= this.getValue();
2104 if (L
.isObject(this.options
.classes
) && this.options
.classes
.hasOwnProperty(val
))
2105 sb
.setAttribute('class', 'cbi-dropdown ' + this.options
.classes
[val
]);
2107 sb
.setAttribute('class', 'cbi-dropdown');
2114 * Instantiate a dynamic list widget.
2116 * @constructor DynamicList
2118 * @augments LuCI.ui.AbstractElement
2122 * The `DynamicList` class implements a widget which allows the user to specify
2123 * an arbitrary amount of input values, either from free formed text input or
2124 * from a set of predefined choices.
2126 * UI widget instances are usually not supposed to be created by view code
2127 * directly, instead they're implicitely created by `LuCI.form` when
2128 * instantiating CBI forms.
2130 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2131 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2132 * external JavaScript, use `L.require("ui").then(...)` and access the
2133 * `DynamicList` property of the class instance value.
2135 * @param {string|string[]} [value=null]
2136 * The initial input value(s).
2138 * @param {Object<string, *>} [choices]
2139 * Object containing the selectable choices of the widget. The object keys
2140 * serve as values for the different choices while the values are used as
2141 * choice labels. If omitted, no default choices are presented to the user,
2142 * instead a plain text input field is rendered allowing the user to add
2143 * arbitrary values to the dynamic list.
2145 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2146 * Object describing the widget specific options to initialize the dynamic list.
2148 var UIDynamicList
= UIElement
.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2150 * In case choices are passed to the dynamic list contructor, the widget
2151 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2152 * but enforces specific values for some dropdown properties.
2154 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2155 * @memberof LuCI.ui.DynamicList
2157 * @property {boolean} multiple=false
2158 * Since dynamic lists never allow selecting multiple choices when adding
2159 * another list item, this property is forcibly set to `false`.
2161 * @property {boolean} optional=true
2162 * Since dynamic lists use an embedded dropdown to present a list of
2163 * predefined choice values, the dropdown must be made optional to allow
2164 * it to remain unselected.
2166 __init__: function(values
, choices
, options
) {
2167 if (!Array
.isArray(values
))
2168 values
= (values
!= null && values
!= '') ? [ values
] : [];
2170 if (typeof(choices
) != 'object')
2173 this.values
= values
;
2174 this.choices
= choices
;
2175 this.options
= Object
.assign({}, options
, {
2182 render: function() {
2184 'id': this.options
.id
,
2185 'class': 'cbi-dynlist',
2186 'disabled': this.options
.disabled
? '' : null
2187 }, E('div', { 'class': 'add-item' }));
2190 if (this.options
.placeholder
!= null)
2191 this.options
.select_placeholder
= this.options
.placeholder
;
2193 var cbox
= new UICombobox(null, this.choices
, this.options
);
2195 dl
.lastElementChild
.appendChild(cbox
.render());
2198 var inputEl
= E('input', {
2199 'id': this.options
.id
? 'widget.' + this.options
.id
: null,
2201 'class': 'cbi-input-text',
2202 'placeholder': this.options
.placeholder
,
2203 'disabled': this.options
.disabled
? '' : null
2206 dl
.lastElementChild
.appendChild(inputEl
);
2207 dl
.lastElementChild
.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2209 if (this.options
.datatype
|| this.options
.validate
)
2210 UI
.prototype.addValidator(inputEl
, this.options
.datatype
|| 'string',
2211 true, this.options
.validate
, 'blur', 'keyup');
2214 for (var i
= 0; i
< this.values
.length
; i
++) {
2215 var label
= this.choices
? this.choices
[this.values
[i
]] : null;
2217 if (dom
.elem(label
))
2218 label
= label
.cloneNode(true);
2220 this.addItem(dl
, this.values
[i
], label
);
2223 return this.bind(dl
);
2227 bind: function(dl
) {
2228 dl
.addEventListener('click', L
.bind(this.handleClick
, this));
2229 dl
.addEventListener('keydown', L
.bind(this.handleKeydown
, this));
2230 dl
.addEventListener('cbi-dropdown-change', L
.bind(this.handleDropdownChange
, this));
2234 this.setUpdateEvents(dl
, 'cbi-dynlist-change');
2235 this.setChangeEvents(dl
, 'cbi-dynlist-change');
2237 dom
.bindClassInstance(dl
, this);
2243 addItem: function(dl
, value
, text
, flash
) {
2245 new_item
= E('div', { 'class': flash
? 'item flash' : 'item', 'tabindex': 0 }, [
2246 E('span', {}, [ text
|| value
]),
2249 'name': this.options
.name
,
2250 'value': value
})]);
2252 dl
.querySelectorAll('.item').forEach(function(item
) {
2256 var hidden
= item
.querySelector('input[type="hidden"]');
2258 if (hidden
&& hidden
.parentNode
!== item
)
2261 if (hidden
&& hidden
.value
=== value
)
2266 var ai
= dl
.querySelector('.add-item');
2267 ai
.parentNode
.insertBefore(new_item
, ai
);
2270 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2282 removeItem: function(dl
, item
) {
2283 var value
= item
.querySelector('input[type="hidden"]').value
;
2284 var sb
= dl
.querySelector('.cbi-dropdown');
2286 sb
.querySelectorAll('ul > li').forEach(function(li
) {
2287 if (li
.getAttribute('data-value') === value
) {
2288 if (li
.hasAttribute('dynlistcustom'))
2289 li
.parentNode
.removeChild(li
);
2291 li
.removeAttribute('unselectable');
2295 item
.parentNode
.removeChild(item
);
2297 dl
.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2309 handleClick: function(ev
) {
2310 var dl
= ev
.currentTarget
,
2311 item
= findParent(ev
.target
, '.item');
2313 if (this.options
.disabled
)
2317 this.removeItem(dl
, item
);
2319 else if (matchesElem(ev
.target
, '.cbi-button-add')) {
2320 var input
= ev
.target
.previousElementSibling
;
2321 if (input
.value
.length
&& !input
.classList
.contains('cbi-input-invalid')) {
2322 this.addItem(dl
, input
.value
, null, true);
2329 handleDropdownChange: function(ev
) {
2330 var dl
= ev
.currentTarget
,
2331 sbIn
= ev
.detail
.instance
,
2332 sbEl
= ev
.detail
.element
,
2333 sbVal
= ev
.detail
.value
;
2338 sbIn
.setValues(sbEl
, null);
2339 sbVal
.element
.setAttribute('unselectable', '');
2341 if (sbVal
.element
.hasAttribute('created')) {
2342 sbVal
.element
.removeAttribute('created');
2343 sbVal
.element
.setAttribute('dynlistcustom', '');
2346 var label
= sbVal
.text
;
2348 if (sbVal
.element
) {
2351 for (var i
= 0; i
< sbVal
.element
.childNodes
.length
; i
++)
2352 label
.appendChild(sbVal
.element
.childNodes
[i
].cloneNode(true));
2355 this.addItem(dl
, sbVal
.value
, label
, true);
2359 handleKeydown: function(ev
) {
2360 var dl
= ev
.currentTarget
,
2361 item
= findParent(ev
.target
, '.item');
2364 switch (ev
.keyCode
) {
2365 case 8: /* backspace */
2366 if (item
.previousElementSibling
)
2367 item
.previousElementSibling
.focus();
2369 this.removeItem(dl
, item
);
2372 case 46: /* delete */
2373 if (item
.nextElementSibling
) {
2374 if (item
.nextElementSibling
.classList
.contains('item'))
2375 item
.nextElementSibling
.focus();
2377 item
.nextElementSibling
.firstElementChild
.focus();
2380 this.removeItem(dl
, item
);
2384 else if (matchesElem(ev
.target
, '.cbi-input-text')) {
2385 switch (ev
.keyCode
) {
2386 case 13: /* enter */
2387 if (ev
.target
.value
.length
&& !ev
.target
.classList
.contains('cbi-input-invalid')) {
2388 this.addItem(dl
, ev
.target
.value
, null, true);
2389 ev
.target
.value
= '';
2394 ev
.preventDefault();
2401 getValue: function() {
2402 var items
= this.node
.querySelectorAll('.item > input[type="hidden"]'),
2403 input
= this.node
.querySelector('.add-item > input[type="text"]'),
2406 for (var i
= 0; i
< items
.length
; i
++)
2407 v
.push(items
[i
].value
);
2409 if (input
&& input
.value
!= null && input
.value
.match(/\S/) &&
2410 input
.classList
.contains('cbi-input-invalid') == false &&
2411 v
.filter(function(s
) { return s
== input
.value
}).length
== 0)
2412 v
.push(input
.value
);
2418 setValue: function(values
) {
2419 if (!Array
.isArray(values
))
2420 values
= (values
!= null && values
!= '') ? [ values
] : [];
2422 var items
= this.node
.querySelectorAll('.item');
2424 for (var i
= 0; i
< items
.length
; i
++)
2425 if (items
[i
].parentNode
=== this.node
)
2426 this.removeItem(this.node
, items
[i
]);
2428 for (var i
= 0; i
< values
.length
; i
++)
2429 this.addItem(this.node
, values
[i
],
2430 this.choices
? this.choices
[values
[i
]] : null);
2434 * Add new suggested choices to the dynamic list.
2436 * This function adds further choices to an existing dynamic list,
2437 * ignoring choice values which are already present.
2440 * @memberof LuCI.ui.DynamicList
2441 * @param {string[]} values
2442 * The choice values to add to the dynamic lists suggestion dropdown.
2444 * @param {Object<string, *>} labels
2445 * The choice label values to use when adding suggested choices. If no
2446 * label is found for a particular choice value, the value itself is used
2447 * as label text. Choice labels may be any valid value accepted by
2448 * {@link LuCI.dom#content}.
2450 addChoices: function(values
, labels
) {
2451 var dl
= this.node
.lastElementChild
.firstElementChild
;
2452 dom
.callClassMethod(dl
, 'addChoices', values
, labels
);
2456 * Remove all existing choices from the dynamic list.
2458 * This function removes all preexisting suggested choices from the widget.
2461 * @memberof LuCI.ui.DynamicList
2463 clearChoices: function() {
2464 var dl
= this.node
.lastElementChild
.firstElementChild
;
2465 dom
.callClassMethod(dl
, 'clearChoices');
2470 * Instantiate a hidden input field widget.
2472 * @constructor Hiddenfield
2474 * @augments LuCI.ui.AbstractElement
2478 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2479 * which allows to store form data without exposing it to the user.
2481 * UI widget instances are usually not supposed to be created by view code
2482 * directly, instead they're implicitely created by `LuCI.form` when
2483 * instantiating CBI forms.
2485 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2486 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2487 * external JavaScript, use `L.require("ui").then(...)` and access the
2488 * `Hiddenfield` property of the class instance value.
2490 * @param {string|string[]} [value=null]
2491 * The initial input value.
2493 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2494 * Object describing the widget specific options to initialize the hidden input.
2496 var UIHiddenfield
= UIElement
.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2497 __init__: function(value
, options
) {
2499 this.options
= Object
.assign({
2505 render: function() {
2506 var hiddenEl
= E('input', {
2507 'id': this.options
.id
,
2512 return this.bind(hiddenEl
);
2516 bind: function(hiddenEl
) {
2517 this.node
= hiddenEl
;
2519 dom
.bindClassInstance(hiddenEl
, this);
2525 getValue: function() {
2526 return this.node
.value
;
2530 setValue: function(value
) {
2531 this.node
.value
= value
;
2536 * Instantiate a file upload widget.
2538 * @constructor FileUpload
2540 * @augments LuCI.ui.AbstractElement
2544 * The `FileUpload` class implements a widget which allows the user to upload,
2545 * browse, select and delete files beneath a predefined remote directory.
2547 * UI widget instances are usually not supposed to be created by view code
2548 * directly, instead they're implicitely created by `LuCI.form` when
2549 * instantiating CBI forms.
2551 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2552 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2553 * external JavaScript, use `L.require("ui").then(...)` and access the
2554 * `FileUpload` property of the class instance value.
2556 * @param {string|string[]} [value=null]
2557 * The initial input value.
2559 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2560 * Object describing the widget specific options to initialize the file
2563 var UIFileUpload
= UIElement
.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2565 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2566 * the following properties are recognized:
2568 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2569 * @memberof LuCI.ui.FileUpload
2571 * @property {boolean} [show_hidden=false]
2572 * Specifies whether hidden files should be displayed when browsing remote
2573 * files. Note that this is not a security feature, hidden files are always
2574 * present in the remote file listings received, this option merely controls
2575 * whether they're displayed or not.
2577 * @property {boolean} [enable_upload=true]
2578 * Specifies whether the widget allows the user to upload files. If set to
2579 * `false`, only existing files may be selected. Note that this is not a
2580 * security feature. Whether file upload requests are accepted remotely
2581 * depends on the ACL setup for the current session. This option merely
2582 * controls whether the upload controls are rendered or not.
2584 * @property {boolean} [enable_remove=true]
2585 * Specifies whether the widget allows the user to delete remove files.
2586 * If set to `false`, existing files may not be removed. Note that this is
2587 * not a security feature. Whether file delete requests are accepted
2588 * remotely depends on the ACL setup for the current session. This option
2589 * merely controls whether the file remove controls are rendered or not.
2591 * @property {string} [root_directory=/etc/luci-uploads]
2592 * Specifies the remote directory the upload and file browsing actions take
2593 * place in. Browsing to directories outside of the root directory is
2594 * prevented by the widget. Note that this is not a security feature.
2595 * Whether remote directories are browseable or not solely depends on the
2596 * ACL setup for the current session.
2598 __init__: function(value
, options
) {
2600 this.options
= Object
.assign({
2602 enable_upload
: true,
2603 enable_remove
: true,
2604 root_directory
: '/etc/luci-uploads'
2609 bind: function(browserEl
) {
2610 this.node
= browserEl
;
2612 this.setUpdateEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2613 this.setChangeEvents(browserEl
, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2615 dom
.bindClassInstance(browserEl
, this);
2621 render: function() {
2622 return L
.resolveDefault(this.value
!= null ? fs
.stat(this.value
) : null).then(L
.bind(function(stat
) {
2625 if (L
.isObject(stat
) && stat
.type
!= 'directory')
2628 if (this.stat
!= null)
2629 label
= [ this.iconForType(this.stat
.type
), ' %s (%1000mB)'.format(this.truncatePath(this.stat
.path
), this.stat
.size
) ];
2630 else if (this.value
!= null)
2631 label
= [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value
), _('File not accessible')) ];
2633 label
= [ _('Select file…') ];
2635 return this.bind(E('div', { 'id': this.options
.id
}, [
2638 'click': UI
.prototype.createHandlerFn(this, 'handleFileBrowser'),
2639 'disabled': this.options
.disabled
? '' : null
2642 'class': 'cbi-filebrowser'
2646 'name': this.options
.name
,
2654 truncatePath: function(path
) {
2655 if (path
.length
> 50)
2656 path
= path
.substring(0, 25) + '…' + path
.substring(path
.length
- 25);
2662 iconForType: function(type
) {
2666 'src': L
.resource('cbi/link.svg'),
2668 'title': _('Symbolic link'),
2674 'src': L
.resource('cbi/folder.svg'),
2676 'title': _('Directory'),
2682 'src': L
.resource('cbi/file.svg'),
2691 canonicalizePath: function(path
) {
2692 return path
.replace(/\/{2,}/, '/')
2693 .replace(/\/\.(\/|$)/g, '/')
2694 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2695 .replace(/\/$/, '');
2699 splitPath: function(path
) {
2700 var croot
= this.canonicalizePath(this.options
.root_directory
|| '/'),
2701 cpath
= this.canonicalizePath(path
|| '/');
2703 if (cpath
.length
<= croot
.length
)
2706 if (cpath
.charAt(croot
.length
) != '/')
2709 var parts
= cpath
.substring(croot
.length
+ 1).split(/\//);
2711 parts
.unshift(croot
);
2717 handleUpload: function(path
, list
, ev
) {
2718 var form
= ev
.target
.parentNode
,
2719 fileinput
= form
.querySelector('input[type="file"]'),
2720 nameinput
= form
.querySelector('input[type="text"]'),
2721 filename
= (nameinput
.value
!= null ? nameinput
.value
: '').trim();
2723 ev
.preventDefault();
2725 if (filename
== '' || filename
.match(/\//) || fileinput
.files
[0] == null)
2728 var existing
= list
.filter(function(e
) { return e
.name
== filename
})[0];
2730 if (existing
!= null && existing
.type
== 'directory')
2731 return alert(_('A directory with the same name already exists.'));
2732 else if (existing
!= null && !confirm(_('Overwrite existing file "%s" ?').format(filename
)))
2735 var data
= new FormData();
2737 data
.append('sessionid', L
.env
.sessionid
);
2738 data
.append('filename', path
+ '/' + filename
);
2739 data
.append('filedata', fileinput
.files
[0]);
2741 return request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
2742 progress
: L
.bind(function(btn
, ev
) {
2743 btn
.firstChild
.data
= '%.2f%%'.format((ev
.loaded
/ ev
.total
) * 100);
2745 }).then(L
.bind(function(path
, ev
, res
) {
2746 var reply
= res
.json();
2748 if (L
.isObject(reply
) && reply
.failure
)
2749 alert(_('Upload request failed: %s').format(reply
.message
));
2751 return this.handleSelect(path
, null, ev
);
2752 }, this, path
, ev
));
2756 handleDelete: function(path
, fileStat
, ev
) {
2757 var parent
= path
.replace(/\/[^\/]+$/, '') || '/',
2758 name
= path
.replace(/^.+\//, ''),
2761 ev
.preventDefault();
2763 if (fileStat
.type
== 'directory')
2764 msg
= _('Do you really want to recursively delete the directory "%s" ?').format(name
);
2766 msg
= _('Do you really want to delete "%s" ?').format(name
);
2769 var button
= this.node
.firstElementChild
,
2770 hidden
= this.node
.lastElementChild
;
2772 if (path
== hidden
.value
) {
2773 dom
.content(button
, _('Select file…'));
2777 return fs
.remove(path
).then(L
.bind(function(parent
, ev
) {
2778 return this.handleSelect(parent
, null, ev
);
2779 }, this, parent
, ev
)).catch(function(err
) {
2780 alert(_('Delete request failed: %s').format(err
.message
));
2786 renderUpload: function(path
, list
) {
2787 if (!this.options
.enable_upload
)
2793 'class': 'btn cbi-button-positive',
2794 'click': function(ev
) {
2795 var uploadForm
= ev
.target
.nextElementSibling
,
2796 fileInput
= uploadForm
.querySelector('input[type="file"]');
2798 ev
.target
.style
.display
= 'none';
2799 uploadForm
.style
.display
= '';
2802 }, _('Upload file…')),
2803 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2806 'style': 'display:none',
2807 'change': function(ev
) {
2808 var nameinput
= ev
.target
.parentNode
.querySelector('input[type="text"]'),
2809 uploadbtn
= ev
.target
.parentNode
.querySelector('button.cbi-button-save');
2811 nameinput
.value
= ev
.target
.value
.replace(/^.+[\/\\]/, '');
2812 uploadbtn
.disabled
= false;
2817 'click': function(ev
) {
2818 ev
.preventDefault();
2819 ev
.target
.previousElementSibling
.click();
2821 }, [ _('Browse…') ]),
2822 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2824 'class': 'btn cbi-button-save',
2825 'click': UI
.prototype.createHandlerFn(this, 'handleUpload', path
, list
),
2827 }, [ _('Upload file') ])
2833 renderListing: function(container
, path
, list
) {
2834 var breadcrumb
= E('p'),
2837 list
.sort(function(a
, b
) {
2838 var isDirA
= (a
.type
== 'directory'),
2839 isDirB
= (b
.type
== 'directory');
2841 if (isDirA
!= isDirB
)
2842 return isDirA
< isDirB
;
2844 return a
.name
> b
.name
;
2847 for (var i
= 0; i
< list
.length
; i
++) {
2848 if (!this.options
.show_hidden
&& list
[i
].name
.charAt(0) == '.')
2851 var entrypath
= this.canonicalizePath(path
+ '/' + list
[i
].name
),
2852 selected
= (entrypath
== this.node
.lastElementChild
.value
),
2853 mtime
= new Date(list
[i
].mtime
* 1000);
2855 rows
.appendChild(E('li', [
2856 E('div', { 'class': 'name' }, [
2857 this.iconForType(list
[i
].type
),
2861 'style': selected
? 'font-weight:bold' : null,
2862 'click': UI
.prototype.createHandlerFn(this, 'handleSelect',
2863 entrypath
, list
[i
].type
!= 'directory' ? list
[i
] : null)
2864 }, '%h'.format(list
[i
].name
))
2866 E('div', { 'class': 'mtime hide-xs' }, [
2867 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2868 mtime
.getFullYear(),
2869 mtime
.getMonth() + 1,
2876 selected
? E('button', {
2878 'click': UI
.prototype.createHandlerFn(this, 'handleReset')
2879 }, [ _('Deselect') ]) : '',
2880 this.options
.enable_remove
? E('button', {
2881 'class': 'btn cbi-button-negative',
2882 'click': UI
.prototype.createHandlerFn(this, 'handleDelete', entrypath
, list
[i
])
2883 }, [ _('Delete') ]) : ''
2888 if (!rows
.firstElementChild
)
2889 rows
.appendChild(E('em', _('No entries in this directory')));
2891 var dirs
= this.splitPath(path
),
2894 for (var i
= 0; i
< dirs
.length
; i
++) {
2895 cur
= cur
? cur
+ '/' + dirs
[i
] : dirs
[i
];
2896 dom
.append(breadcrumb
, [
2900 'click': UI
.prototype.createHandlerFn(this, 'handleSelect', cur
|| '/', null)
2901 }, dirs
[i
] != '' ? '%h'.format(dirs
[i
]) : E('em', '(root)')),
2905 dom
.content(container
, [
2908 E('div', { 'class': 'right' }, [
2909 this.renderUpload(path
, list
),
2913 'click': UI
.prototype.createHandlerFn(this, 'handleCancel')
2920 handleCancel: function(ev
) {
2921 var button
= this.node
.firstElementChild
,
2922 browser
= button
.nextElementSibling
;
2924 browser
.classList
.remove('open');
2925 button
.style
.display
= '';
2927 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2929 ev
.preventDefault();
2933 handleReset: function(ev
) {
2934 var button
= this.node
.firstElementChild
,
2935 hidden
= this.node
.lastElementChild
;
2938 dom
.content(button
, _('Select file…'));
2940 this.handleCancel(ev
);
2944 handleSelect: function(path
, fileStat
, ev
) {
2945 var browser
= dom
.parent(ev
.target
, '.cbi-filebrowser'),
2946 ul
= browser
.querySelector('ul');
2948 if (fileStat
== null) {
2949 dom
.content(ul
, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2950 L
.resolveDefault(fs
.list(path
), []).then(L
.bind(this.renderListing
, this, browser
, path
));
2953 var button
= this.node
.firstElementChild
,
2954 hidden
= this.node
.lastElementChild
;
2956 path
= this.canonicalizePath(path
);
2958 dom
.content(button
, [
2959 this.iconForType(fileStat
.type
),
2960 ' %s (%1000mB)'.format(this.truncatePath(path
), fileStat
.size
)
2963 browser
.classList
.remove('open');
2964 button
.style
.display
= '';
2965 hidden
.value
= path
;
2967 this.stat
= Object
.assign({ path
: path
}, fileStat
);
2968 this.node
.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail
: this.stat
}));
2973 handleFileBrowser: function(ev
) {
2974 var button
= ev
.target
,
2975 browser
= button
.nextElementSibling
,
2976 path
= this.stat
? this.stat
.path
.replace(/\/[^\/]+$/, '') : (this.options
.initial_directory
|| this.options
.root_directory
);
2978 if (path
.indexOf(this.options
.root_directory
) != 0)
2979 path
= this.options
.root_directory
;
2981 ev
.preventDefault();
2983 return L
.resolveDefault(fs
.list(path
), []).then(L
.bind(function(button
, browser
, path
, list
) {
2984 document
.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl
) {
2985 dom
.findClassInstance(browserEl
).handleCancel(ev
);
2988 button
.style
.display
= 'none';
2989 browser
.classList
.add('open');
2991 return this.renderListing(browser
, path
, list
);
2992 }, this, button
, browser
, path
));
2996 getValue: function() {
2997 return this.node
.lastElementChild
.value
;
3001 setValue: function(value
) {
3002 this.node
.lastElementChild
.value
= value
;
3007 function scrubMenu(node
) {
3008 var hasSatisfiedChild
= false;
3010 if (L
.isObject(node
.children
)) {
3011 for (var k
in node
.children
) {
3012 var child
= scrubMenu(node
.children
[k
]);
3015 hasSatisfiedChild
= hasSatisfiedChild
|| child
.satisfied
;
3019 if (L
.isObject(node
.action
) &&
3020 node
.action
.type
== 'firstchild' &&
3021 hasSatisfiedChild
== false)
3022 node
.satisfied
= false;
3037 var UIMenu
= baseclass
.singleton(/** @lends LuCI.ui.menu.prototype */ {
3039 * @typedef {Object} MenuNode
3040 * @memberof LuCI.ui.menu
3042 * @property {string} name - The internal name of the node, as used in the URL
3043 * @property {number} order - The sort index of the menu node
3044 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
3045 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
3046 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
3047 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
3051 * Load and cache current menu tree.
3053 * @returns {Promise<LuCI.ui.menu.MenuNode>}
3054 * Returns a promise resolving to the root element of the menu tree.
3057 if (this.menu
== null)
3058 this.menu
= session
.getLocalData('menu');
3060 if (!L
.isObject(this.menu
)) {
3061 this.menu
= request
.get(L
.url('admin/menu')).then(L
.bind(function(menu
) {
3062 this.menu
= scrubMenu(menu
.json());
3063 session
.setLocalData('menu', this.menu
);
3069 return Promise
.resolve(this.menu
);
3073 * Flush the internal menu cache to force loading a new structure on the
3076 flushCache: function() {
3077 session
.setLocalData('menu', null);
3081 * @param {LuCI.ui.menu.MenuNode} [node]
3082 * The menu node to retrieve the children for. Defaults to the menu's
3083 * internal root node if omitted.
3085 * @returns {LuCI.ui.menu.MenuNode[]}
3086 * Returns an array of child menu nodes.
3088 getChildren: function(node
) {
3094 for (var k
in node
.children
) {
3095 if (!node
.children
.hasOwnProperty(k
))
3098 if (!node
.children
[k
].satisfied
)
3101 if (!node
.children
[k
].hasOwnProperty('title'))
3104 children
.push(Object
.assign(node
.children
[k
], { name
: k
}));
3107 return children
.sort(function(a
, b
) {
3108 var wA
= a
.order
|| 1000,
3109 wB
= b
.order
|| 1000;
3114 return a
.name
> b
.name
;
3125 * Provides high level UI helper functionality.
3126 * To import the class in views, use `'require ui'`, to import it in
3127 * external JavaScript, use `L.require("ui").then(...)`.
3129 var UI
= baseclass
.extend(/** @lends LuCI.ui.prototype */ {
3130 __init__: function() {
3131 modalDiv
= document
.body
.appendChild(
3132 dom
.create('div', { id
: 'modal_overlay' },
3133 dom
.create('div', { class: 'modal', role
: 'dialog', 'aria-modal': true })));
3135 tooltipDiv
= document
.body
.appendChild(
3136 dom
.create('div', { class: 'cbi-tooltip' }));
3138 /* setup old aliases */
3139 L
.showModal
= this.showModal
;
3140 L
.hideModal
= this.hideModal
;
3141 L
.showTooltip
= this.showTooltip
;
3142 L
.hideTooltip
= this.hideTooltip
;
3143 L
.itemlist
= this.itemlist
;
3145 document
.addEventListener('mouseover', this.showTooltip
.bind(this), true);
3146 document
.addEventListener('mouseout', this.hideTooltip
.bind(this), true);
3147 document
.addEventListener('focus', this.showTooltip
.bind(this), true);
3148 document
.addEventListener('blur', this.hideTooltip
.bind(this), true);
3150 document
.addEventListener('luci-loaded', this.tabs
.init
.bind(this.tabs
));
3151 document
.addEventListener('luci-loaded', this.changes
.init
.bind(this.changes
));
3152 document
.addEventListener('uci-loaded', this.changes
.init
.bind(this.changes
));
3156 * Display a modal overlay dialog with the specified contents.
3158 * The modal overlay dialog covers the current view preventing interaction
3159 * with the underlying view contents. Only one modal dialog instance can
3160 * be opened. Invoking showModal() while a modal dialog is already open will
3161 * replace the open dialog with a new one having the specified contents.
3163 * Additional CSS class names may be passed to influence the appearence of
3164 * the dialog. Valid values for the classes depend on the underlying theme.
3166 * @see LuCI.dom.content
3168 * @param {string} [title]
3169 * The title of the dialog. If `null`, no title element will be rendered.
3171 * @param {*} contents
3172 * The contents to add to the modal dialog. This should be a DOM node or
3173 * a document fragment in most cases. The value is passed as-is to the
3174 * `dom.content()` function - refer to its documentation for applicable
3177 * @param {...string} [classes]
3178 * A number of extra CSS class names which are set on the modal dialog
3182 * Returns a DOM Node representing the modal dialog element.
3184 showModal: function(title
, children
/* , ... */) {
3185 var dlg
= modalDiv
.firstElementChild
;
3187 dlg
.setAttribute('class', 'modal');
3189 for (var i
= 2; i
< arguments
.length
; i
++)
3190 dlg
.classList
.add(arguments
[i
]);
3192 dom
.content(dlg
, dom
.create('h4', {}, title
));
3193 dom
.append(dlg
, children
);
3195 document
.body
.classList
.add('modal-overlay-active');
3196 modalDiv
.scrollTop
= 0;
3202 * Close the open modal overlay dialog.
3204 * This function will close an open modal dialog and restore the normal view
3205 * behaviour. It has no effect if no modal dialog is currently open.
3207 * Note that this function is stand-alone, it does not rely on `this` and
3208 * will not invoke other class functions so it suitable to be used as event
3209 * handler as-is without the need to bind it first.
3211 hideModal: function() {
3212 document
.body
.classList
.remove('modal-overlay-active');
3216 showTooltip: function(ev
) {
3217 var target
= findParent(ev
.target
, '[data-tooltip]');
3222 if (tooltipTimeout
!== null) {
3223 window
.clearTimeout(tooltipTimeout
);
3224 tooltipTimeout
= null;
3227 var rect
= target
.getBoundingClientRect(),
3228 x
= rect
.left
+ window
.pageXOffset
,
3229 y
= rect
.top
+ rect
.height
+ window
.pageYOffset
;
3231 tooltipDiv
.className
= 'cbi-tooltip';
3232 tooltipDiv
.innerHTML
= '▲ ';
3233 tooltipDiv
.firstChild
.data
+= target
.getAttribute('data-tooltip');
3235 if (target
.hasAttribute('data-tooltip-style'))
3236 tooltipDiv
.classList
.add(target
.getAttribute('data-tooltip-style'));
3238 if ((y
+ tooltipDiv
.offsetHeight
) > (window
.innerHeight
+ window
.pageYOffset
)) {
3239 y
-= (tooltipDiv
.offsetHeight
+ target
.offsetHeight
);
3240 tooltipDiv
.firstChild
.data
= '▼ ' + tooltipDiv
.firstChild
.data
.substr(2);
3243 tooltipDiv
.style
.top
= y
+ 'px';
3244 tooltipDiv
.style
.left
= x
+ 'px';
3245 tooltipDiv
.style
.opacity
= 1;
3247 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-open', {
3249 detail
: { target
: target
}
3254 hideTooltip: function(ev
) {
3255 if (ev
.target
=== tooltipDiv
|| ev
.relatedTarget
=== tooltipDiv
||
3256 tooltipDiv
.contains(ev
.target
) || tooltipDiv
.contains(ev
.relatedTarget
))
3259 if (tooltipTimeout
!== null) {
3260 window
.clearTimeout(tooltipTimeout
);
3261 tooltipTimeout
= null;
3264 tooltipDiv
.style
.opacity
= 0;
3265 tooltipTimeout
= window
.setTimeout(function() { tooltipDiv
.removeAttribute('style'); }, 250);
3267 tooltipDiv
.dispatchEvent(new CustomEvent('tooltip-close', { bubbles
: true }));
3271 * Add a notification banner at the top of the current view.
3273 * A notification banner is an alert message usually displayed at the
3274 * top of the current view, spanning the entire availibe width.
3275 * Notification banners will stay in place until dismissed by the user.
3276 * Multiple banners may be shown at the same time.
3278 * Additional CSS class names may be passed to influence the appearence of
3279 * the banner. Valid values for the classes depend on the underlying theme.
3281 * @see LuCI.dom.content
3283 * @param {string} [title]
3284 * The title of the notification banner. If `null`, no title element
3287 * @param {*} contents
3288 * The contents to add to the notification banner. This should be a DOM
3289 * node or a document fragment in most cases. The value is passed as-is
3290 * to the `dom.content()` function - refer to its documentation for
3291 * applicable values.
3293 * @param {...string} [classes]
3294 * A number of extra CSS class names which are set on the notification
3298 * Returns a DOM Node representing the notification banner element.
3300 addNotification: function(title
, children
/*, ... */) {
3301 var mc
= document
.querySelector('#maincontent') || document
.body
;
3302 var msg
= E('div', {
3303 'class': 'alert-message fade-in',
3304 'style': 'display:flex',
3305 'transitionend': function(ev
) {
3306 var node
= ev
.currentTarget
;
3307 if (node
.parentNode
&& node
.classList
.contains('fade-out'))
3308 node
.parentNode
.removeChild(node
);
3311 E('div', { 'style': 'flex:10' }),
3312 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3315 'style': 'margin-left:auto; margin-top:auto',
3316 'click': function(ev
) {
3317 dom
.parent(ev
.target
, '.alert-message').classList
.add('fade-out');
3320 }, [ _('Dismiss') ])
3325 dom
.append(msg
.firstElementChild
, E('h4', {}, title
));
3327 dom
.append(msg
.firstElementChild
, children
);
3329 for (var i
= 2; i
< arguments
.length
; i
++)
3330 msg
.classList
.add(arguments
[i
]);
3332 mc
.insertBefore(msg
, mc
.firstElementChild
);
3338 * Display or update an header area indicator.
3340 * An indicator is a small label displayed in the header area of the screen
3341 * providing few amounts of status information such as item counts or state
3342 * toggle indicators.
3344 * Multiple indicators may be shown at the same time and indicator labels
3345 * may be made clickable to display extended information or to initiate
3348 * Indicators can either use a default `active` or a less accented `inactive`
3349 * style which is useful for indicators representing state toggles.
3351 * @param {string} id
3352 * The ID of the indicator. If an indicator with the given ID already exists,
3353 * it is updated with the given label and style.
3355 * @param {string} label
3356 * The text to display in the indicator label.
3358 * @param {function} [handler]
3359 * A handler function to invoke when the indicator label is clicked/touched
3360 * by the user. If omitted, the indicator is not clickable/touchable.
3362 * Note that this parameter only applies to new indicators, when updating
3363 * existing labels it is ignored.
3365 * @param {string} [style=active]
3366 * The indicator style to use. May be either `active` or `inactive`.
3368 * @returns {boolean}
3369 * Returns `true` when the indicator has been updated or `false` when no
3370 * changes were made.
3372 showIndicator: function(id
, label
, handler
, style
) {
3373 if (indicatorDiv
== null) {
3374 indicatorDiv
= document
.body
.querySelector('#indicators');
3376 if (indicatorDiv
== null)
3380 var handlerFn
= (typeof(handler
) == 'function') ? handler
: null,
3381 indicatorElem
= indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
));
3383 if (indicatorElem
== null) {
3384 var beforeElem
= null;
3386 for (beforeElem
= indicatorDiv
.firstElementChild
;
3388 beforeElem
= beforeElem
.nextElementSibling
)
3389 if (beforeElem
.getAttribute('data-indicator') > id
)
3392 indicatorElem
= indicatorDiv
.insertBefore(E('span', {
3393 'data-indicator': id
,
3394 'data-clickable': handlerFn
? true : null,
3396 }, ['']), beforeElem
);
3399 if (label
== indicatorElem
.firstChild
.data
&& style
== indicatorElem
.getAttribute('data-style'))
3402 indicatorElem
.firstChild
.data
= label
;
3403 indicatorElem
.setAttribute('data-style', (style
== 'inactive') ? 'inactive' : 'active');
3408 * Remove an header area indicator.
3410 * This function removes the given indicator label from the header indicator
3411 * area. When the given indicator is not found, this function does nothing.
3413 * @param {string} id
3414 * The ID of the indicator to remove.
3416 * @returns {boolean}
3417 * Returns `true` when the indicator has been removed or `false` when the
3418 * requested indicator was not found.
3420 hideIndicator: function(id
) {
3421 var indicatorElem
= indicatorDiv
? indicatorDiv
.querySelector('span[data-indicator="%s"]'.format(id
)) : null;
3423 if (indicatorElem
== null)
3426 indicatorDiv
.removeChild(indicatorElem
);
3431 * Formats a series of label/value pairs into list-like markup.
3433 * This function transforms a flat array of alternating label and value
3434 * elements into a list-like markup, using the values in `separators` as
3435 * separators and appends the resulting nodes to the given parent DOM node.
3437 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3438 * `<strong>` element and the value corresponding to the label are
3439 * subsequently wrapped into a `<span class="nowrap">` element.
3441 * The resulting `<span>` element tuples are joined by the given separators
3442 * to form the final markup which is appened to the given parent DOM node.
3444 * @param {Node} node
3445 * The parent DOM node to append the markup to. Any previous child elements
3448 * @param {Array<*>} items
3449 * An alternating array of labels and values. The label values will be
3450 * converted to plain strings, the values are used as-is and may be of
3451 * any type accepted by `LuCI.dom.content()`.
3453 * @param {*|Array<*>} [separators=[E('br')]]
3454 * A single value or an array of separator values to separate each
3455 * label/value pair with. The function will cycle through the separators
3456 * when joining the pairs. If omitted, the default separator is a sole HTML
3457 * `<br>` element. Separator values are used as-is and may be of any type
3458 * accepted by `LuCI.dom.content()`.
3461 * Returns the parent DOM node the formatted markup has been added to.
3463 itemlist: function(node
, items
, separators
) {
3466 if (!Array
.isArray(separators
))
3467 separators
= [ separators
|| E('br') ];
3469 for (var i
= 0; i
< items
.length
; i
+= 2) {
3470 if (items
[i
+1] !== null && items
[i
+1] !== undefined) {
3471 var sep
= separators
[(i
/2) % separators
.length
],
3474 children
.push(E('span', { class: 'nowrap' }, [
3475 items
[i
] ? E('strong', items
[i
] + ': ') : '',
3479 if ((i
+2) < items
.length
)
3480 children
.push(dom
.elem(sep
) ? sep
.cloneNode(true) : sep
);
3484 dom
.content(node
, children
);
3495 * The `tabs` class handles tab menu groups used throughout the view area.
3496 * It takes care of setting up tab groups, tracking their state and handling
3499 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3500 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3501 * external JavaScript, use `L.require("ui").then(...)` and access the
3502 * `tabs` property of the class instance value.
3504 tabs
: baseclass
.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3507 var groups
= [], prevGroup
= null, currGroup
= null;
3509 document
.querySelectorAll('[data-tab]').forEach(function(tab
) {
3510 var parent
= tab
.parentNode
;
3512 if (dom
.matches(tab
, 'li') && dom
.matches(parent
, 'ul.cbi-tabmenu'))
3515 if (!parent
.hasAttribute('data-tab-group'))
3516 parent
.setAttribute('data-tab-group', groups
.length
);
3518 currGroup
= +parent
.getAttribute('data-tab-group');
3520 if (currGroup
!== prevGroup
) {
3521 prevGroup
= currGroup
;
3523 if (!groups
[currGroup
])
3524 groups
[currGroup
] = [];
3527 groups
[currGroup
].push(tab
);
3530 for (var i
= 0; i
< groups
.length
; i
++)
3531 this.initTabGroup(groups
[i
]);
3533 document
.addEventListener('dependency-update', this.updateTabs
.bind(this));
3539 * Initializes a new tab group from the given tab pane collection.
3541 * This function cycles through the given tab pane DOM nodes, extracts
3542 * their tab IDs, titles and active states, renders a corresponding
3543 * tab menu and prepends it to the tab panes common parent DOM node.
3545 * The tab menu labels will be set to the value of the `data-tab-title`
3546 * attribute of each corresponding pane. The last pane with the
3547 * `data-tab-active` attribute set to `true` will be selected by default.
3549 * If no pane is marked as active, the first one will be preselected.
3552 * @memberof LuCI.ui.tabs
3553 * @param {Array<Node>|NodeList} panes
3554 * A collection of tab panes to build a tab group menu for. May be a
3555 * plain array of DOM nodes or a NodeList collection, such as the result
3556 * of a `querySelectorAll()` call or the `.childNodes` property of a
3559 initTabGroup: function(panes
) {
3560 if (typeof(panes
) != 'object' || !('length' in panes
) || panes
.length
=== 0)
3563 var menu
= E('ul', { 'class': 'cbi-tabmenu' }),
3564 group
= panes
[0].parentNode
,
3565 groupId
= +group
.getAttribute('data-tab-group'),
3568 if (group
.getAttribute('data-initialized') === 'true')
3571 for (var i
= 0, pane
; pane
= panes
[i
]; i
++) {
3572 var name
= pane
.getAttribute('data-tab'),
3573 title
= pane
.getAttribute('data-tab-title'),
3574 active
= pane
.getAttribute('data-tab-active') === 'true';
3576 menu
.appendChild(E('li', {
3577 'style': this.isEmptyPane(pane
) ? 'display:none' : null,
3578 'class': active
? 'cbi-tab' : 'cbi-tab-disabled',
3582 'click': this.switchTab
.bind(this)
3589 group
.parentNode
.insertBefore(menu
, group
);
3590 group
.setAttribute('data-initialized', true);
3592 if (selected
=== null) {
3593 selected
= this.getActiveTabId(panes
[0]);
3595 if (selected
< 0 || selected
>= panes
.length
|| this.isEmptyPane(panes
[selected
])) {
3596 for (var i
= 0; i
< panes
.length
; i
++) {
3597 if (!this.isEmptyPane(panes
[i
])) {
3604 menu
.childNodes
[selected
].classList
.add('cbi-tab');
3605 menu
.childNodes
[selected
].classList
.remove('cbi-tab-disabled');
3606 panes
[selected
].setAttribute('data-tab-active', 'true');
3608 this.setActiveTabId(panes
[selected
], selected
);
3611 requestAnimationFrame(L
.bind(function(pane
) {
3612 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', {
3613 detail
: { tab
: pane
.getAttribute('data-tab') }
3615 }, this, panes
[selected
]));
3617 this.updateTabs(group
);
3621 * Checks whether the given tab pane node is empty.
3624 * @memberof LuCI.ui.tabs
3625 * @param {Node} pane
3626 * The tab pane to check.
3628 * @returns {boolean}
3629 * Returns `true` if the pane is empty, else `false`.
3631 isEmptyPane: function(pane
) {
3632 return dom
.isEmpty(pane
, function(n
) { return n
.classList
.contains('cbi-tab-descr') });
3636 getPathForPane: function(pane
) {
3637 var path
= [], node
= null;
3639 for (node
= pane
? pane
.parentNode
: null;
3640 node
!= null && node
.hasAttribute
!= null;
3641 node
= node
.parentNode
)
3643 if (node
.hasAttribute('data-tab'))
3644 path
.unshift(node
.getAttribute('data-tab'));
3645 else if (node
.hasAttribute('data-section-id'))
3646 path
.unshift(node
.getAttribute('data-section-id'));
3649 return path
.join('/');
3653 getActiveTabState: function() {
3654 var page
= document
.body
.getAttribute('data-page'),
3655 state
= session
.getLocalData('tab');
3657 if (L
.isObject(state
) && state
.page
=== page
&& L
.isObject(state
.paths
))
3660 session
.setLocalData('tab', null);
3662 return { page
: page
, paths
: {} };
3666 getActiveTabId: function(pane
) {
3667 var path
= this.getPathForPane(pane
);
3668 return +this.getActiveTabState().paths
[path
] || 0;
3672 setActiveTabId: function(pane
, tabIndex
) {
3673 var path
= this.getPathForPane(pane
),
3674 state
= this.getActiveTabState();
3676 state
.paths
[path
] = tabIndex
;
3678 return session
.setLocalData('tab', state
);
3682 updateTabs: function(ev
, root
) {
3683 (root
|| document
).querySelectorAll('[data-tab-title]').forEach(L
.bind(function(pane
) {
3684 var menu
= pane
.parentNode
.previousElementSibling
,
3685 tab
= menu
? menu
.querySelector('[data-tab="%s"]'.format(pane
.getAttribute('data-tab'))) : null,
3686 n_errors
= pane
.querySelectorAll('.cbi-input-invalid').length
;
3691 if (this.isEmptyPane(pane
)) {
3692 tab
.style
.display
= 'none';
3693 tab
.classList
.remove('flash');
3695 else if (tab
.style
.display
=== 'none') {
3696 tab
.style
.display
= '';
3697 requestAnimationFrame(function() { tab
.classList
.add('flash') });
3701 tab
.setAttribute('data-errors', n_errors
);
3702 tab
.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors
));
3703 tab
.setAttribute('data-tooltip-style', 'error');
3706 tab
.removeAttribute('data-errors');
3707 tab
.removeAttribute('data-tooltip');
3713 switchTab: function(ev
) {
3714 var tab
= ev
.target
.parentNode
,
3715 name
= tab
.getAttribute('data-tab'),
3716 menu
= tab
.parentNode
,
3717 group
= menu
.nextElementSibling
,
3718 groupId
= +group
.getAttribute('data-tab-group'),
3721 ev
.preventDefault();
3723 if (!tab
.classList
.contains('cbi-tab-disabled'))
3726 menu
.querySelectorAll('[data-tab]').forEach(function(tab
) {
3727 tab
.classList
.remove('cbi-tab');
3728 tab
.classList
.remove('cbi-tab-disabled');
3730 tab
.getAttribute('data-tab') === name
? 'cbi-tab' : 'cbi-tab-disabled');
3733 group
.childNodes
.forEach(function(pane
) {
3734 if (dom
.matches(pane
, '[data-tab]')) {
3735 if (pane
.getAttribute('data-tab') === name
) {
3736 pane
.setAttribute('data-tab-active', 'true');
3737 pane
.dispatchEvent(new CustomEvent('cbi-tab-active', { detail
: { tab
: name
} }));
3738 UI
.prototype.tabs
.setActiveTabId(pane
, index
);
3741 pane
.setAttribute('data-tab-active', 'false');
3751 * @typedef {Object} FileUploadReply
3754 * @property {string} name - Name of the uploaded file without directory components
3755 * @property {number} size - Size of the uploaded file in bytes
3756 * @property {string} checksum - The MD5 checksum of the received file data
3757 * @property {string} sha256sum - The SHA256 checksum of the received file data
3761 * Display a modal file upload prompt.
3763 * This function opens a modal dialog prompting the user to select and
3764 * upload a file to a predefined remote destination path.
3766 * @param {string} path
3767 * The remote file path to upload the local file to.
3769 * @param {Node} [progessStatusNode]
3770 * An optional DOM text node whose content text is set to the progress
3771 * percentage value during file upload.
3773 * @returns {Promise<LuCI.ui.FileUploadReply>}
3774 * Returns a promise resolving to a file upload status object on success
3775 * or rejecting with an error in case the upload failed or has been
3776 * cancelled by the user.
3778 uploadFile: function(path
, progressStatusNode
) {
3779 return new Promise(function(resolveFn
, rejectFn
) {
3780 UI
.prototype.showModal(_('Uploading file…'), [
3781 E('p', _('Please select the file to upload.')),
3782 E('div', { 'style': 'display:flex' }, [
3783 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3786 style
: 'display:none',
3787 change: function(ev
) {
3788 var modal
= dom
.parent(ev
.target
, '.modal'),
3789 body
= modal
.querySelector('p'),
3790 upload
= modal
.querySelector('.cbi-button-action.important'),
3791 file
= ev
.currentTarget
.files
[0];
3798 E('li', {}, [ '%s: %s'.format(_('Name'), file
.name
.replace(/^.*[\\\/]/, '')) ]),
3799 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file
.size
) ])
3803 upload
.disabled
= false;
3809 'click': function(ev
) {
3810 ev
.target
.previousElementSibling
.click();
3812 }, [ _('Browse…') ])
3814 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3817 'click': function() {
3818 UI
.prototype.hideModal();
3819 rejectFn(new Error('Upload has been cancelled'));
3821 }, [ _('Cancel') ]),
3824 'class': 'btn cbi-button-action important',
3826 'click': function(ev
) {
3827 var input
= dom
.parent(ev
.target
, '.modal').querySelector('input[type="file"]');
3829 if (!input
.files
[0])
3832 var progress
= E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3834 UI
.prototype.showModal(_('Uploading file…'), [ progress
]);
3836 var data
= new FormData();
3838 data
.append('sessionid', rpc
.getSessionID());
3839 data
.append('filename', path
);
3840 data
.append('filedata', input
.files
[0]);
3842 var filename
= input
.files
[0].name
;
3844 request
.post(L
.env
.cgi_base
+ '/cgi-upload', data
, {
3846 progress: function(pev
) {
3847 var percent
= (pev
.loaded
/ pev
.total
) * 100;
3849 if (progressStatusNode
)
3850 progressStatusNode
.data
= '%.2f%%'.format(percent
);
3852 progress
.setAttribute('title', '%.2f%%'.format(percent
));
3853 progress
.firstElementChild
.style
.width
= '%.2f%%'.format(percent
);
3855 }).then(function(res
) {
3856 var reply
= res
.json();
3858 UI
.prototype.hideModal();
3860 if (L
.isObject(reply
) && reply
.failure
) {
3861 UI
.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply
.message
)));
3862 rejectFn(new Error(reply
.failure
));
3865 reply
.name
= filename
;
3869 UI
.prototype.hideModal();
3881 * Perform a device connectivity test.
3883 * Attempt to fetch a well known ressource from the remote device via HTTP
3884 * in order to test connectivity. This function is mainly useful to wait
3885 * for the router to come back online after a reboot or reconfiguration.
3887 * @param {string} [proto=http]
3888 * The protocol to use for fetching the resource. May be either `http`
3889 * (the default) or `https`.
3891 * @param {string} [host=window.location.host]
3892 * Override the host address to probe. By default the current host as seen
3893 * in the address bar is probed.
3895 * @returns {Promise<Event>}
3896 * Returns a promise resolving to a `load` event in case the device is
3897 * reachable or rejecting with an `error` event in case it is not reachable
3898 * or rejecting with `null` when the connectivity check timed out.
3900 pingDevice: function(proto
, ipaddr
) {
3901 var target
= '%s://%s%s?%s'.format(proto
|| 'http', ipaddr
|| window
.location
.host
, L
.resource('icons/loading.gif'), Math
.random());
3903 return new Promise(function(resolveFn
, rejectFn
) {
3904 var img
= new Image();
3906 img
.onload
= resolveFn
;
3907 img
.onerror
= rejectFn
;
3909 window
.setTimeout(rejectFn
, 1000);
3916 * Wait for device to come back online and reconnect to it.
3918 * Poll each given hostname or IP address and navigate to it as soon as
3919 * one of the addresses becomes reachable.
3921 * @param {...string} [hosts=[window.location.host]]
3922 * The list of IP addresses and host names to check for reachability.
3923 * If omitted, the current value of `window.location.host` is used by
3926 awaitReconnect: function(/* ... */) {
3927 var ipaddrs
= arguments
.length
? arguments
: [ window
.location
.host
];
3929 window
.setTimeout(L
.bind(function() {
3930 poll
.add(L
.bind(function() {
3931 var tasks
= [], reachable
= false;
3933 for (var i
= 0; i
< 2; i
++)
3934 for (var j
= 0; j
< ipaddrs
.length
; j
++)
3935 tasks
.push(this.pingDevice(i
? 'https' : 'http', ipaddrs
[j
])
3936 .then(function(ev
) { reachable
= ev
.target
.src
.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3938 return Promise
.all(tasks
).then(function() {
3941 window
.location
= reachable
;
3954 * The `changes` class encapsulates logic for visualizing, applying,
3955 * confirming and reverting staged UCI changesets.
3957 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3958 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3959 * external JavaScript, use `L.require("ui").then(...)` and access the
3960 * `changes` property of the class instance value.
3962 changes
: baseclass
.singleton(/* @lends LuCI.ui.changes.prototype */ {
3964 if (!L
.env
.sessionid
)
3967 return uci
.changes().then(L
.bind(this.renderChangeIndicator
, this));
3971 * Set the change count indicator.
3973 * This function updates or hides the UCI change count indicator,
3974 * depending on the passed change count. When the count is greater
3975 * than 0, the change indicator is displayed or updated, otherwise it
3979 * @memberof LuCI.ui.changes
3980 * @param {number} numChanges
3981 * The number of changes to indicate.
3983 setIndicator: function(n
) {
3985 UI
.prototype.showIndicator('uci-changes',
3986 '%s: %d'.format(_('Unsaved Changes'), n
),
3987 L
.bind(this.displayChanges
, this));
3990 UI
.prototype.hideIndicator('uci-changes');
3995 * Update the change count indicator.
3997 * This function updates the UCI change count indicator from the given
3998 * UCI changeset structure.
4001 * @memberof LuCI.ui.changes
4002 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
4003 * The UCI changeset to count.
4005 renderChangeIndicator: function(changes
) {
4008 for (var config
in changes
)
4009 if (changes
.hasOwnProperty(config
))
4010 n_changes
+= changes
[config
].length
;
4012 this.changes
= changes
;
4013 this.setIndicator(n_changes
);
4018 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
4019 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
4020 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
4021 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
4022 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
4023 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
4024 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
4025 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
4026 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
4027 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
4031 * Display the current changelog.
4033 * Open a modal dialog visualizing the currently staged UCI changes
4034 * and offer options to revert or apply the shown changes.
4037 * @memberof LuCI.ui.changes
4039 displayChanges: function() {
4040 var list
= E('div', { 'class': 'uci-change-list' }),
4041 dlg
= UI
.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
4042 E('div', { 'class': 'cbi-section' }, [
4043 E('strong', _('Legend:')),
4044 E('div', { 'class': 'uci-change-legend' }, [
4045 E('div', { 'class': 'uci-change-legend-label' }, [
4046 E('ins', ' '), ' ', _('Section added') ]),
4047 E('div', { 'class': 'uci-change-legend-label' }, [
4048 E('del', ' '), ' ', _('Section removed') ]),
4049 E('div', { 'class': 'uci-change-legend-label' }, [
4050 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
4051 E('div', { 'class': 'uci-change-legend-label' }, [
4052 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
4054 E('div', { 'class': 'right' }, [
4057 'click': UI
.prototype.hideModal
4058 }, [ _('Dismiss') ]), ' ',
4060 'class': 'cbi-button cbi-button-positive important',
4061 'click': L
.bind(this.apply
, this, true)
4062 }, [ _('Save & Apply') ]), ' ',
4064 'class': 'cbi-button cbi-button-reset',
4065 'click': L
.bind(this.revert
, this)
4066 }, [ _('Revert') ])])])
4069 for (var config
in this.changes
) {
4070 if (!this.changes
.hasOwnProperty(config
))
4073 list
.appendChild(E('h5', '# /etc/config/%s'.format(config
)));
4075 for (var i
= 0, added
= null; i
< this.changes
[config
].length
; i
++) {
4076 var chg
= this.changes
[config
][i
],
4077 tpl
= this.changeTemplates
['%s-%d'.format(chg
[0], chg
.length
)];
4079 list
.appendChild(E(tpl
.replace(/%([01234])/g, function(m0
, m1
) {
4085 if (added
!= null && chg
[1] == added
[0])
4086 return '@' + added
[1] + '[-1]';
4091 return "'%h'".format(chg
[3].replace(/'/g, "'\"'\"'"));
4098 if (chg[0] == 'add')
4099 added = [ chg[1], chg[2] ];
4103 list.appendChild(E('br'));
4104 dlg.classList.add('uci-dialog');
4108 displayStatus: function(type, content) {
4110 var message = UI.prototype.showModal('', '');
4112 message.classList.add('alert-message');
4113 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4116 dom.content(message, content);
4118 if (!this.was_polling) {
4119 this.was_polling = request.poll.active();
4120 request.poll.stop();
4124 UI.prototype.hideModal();
4126 if (this.was_polling)
4127 request.poll.start();
4132 rollback: function(checked) {
4134 this.displayStatus('warning spinning',
4135 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4136 .format(L.env.apply_rollback)));
4138 var call = function(r, data, duration) {
4139 if (r.status === 204) {
4140 UI.prototype.changes.displayStatus('warning', [
4141 E('h4', _('Configuration changes have been rolled back!')),
4142 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)),
4143 E('div', { 'class': 'right' }, [
4146 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4147 }, [ _('Dismiss') ]), ' ',
4149 'class': 'btn cbi-button-action important',
4150 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4151 }, [ _('Revert changes') ]), ' ',
4153 'class': 'btn cbi-button-negative important',
4154 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4155 }, [ _('Apply unchecked') ])
4162 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4163 window.setTimeout(function() {
4164 request.request(L.url('admin/uci/confirm'), {
4166 timeout: L.env.apply_timeout * 1000,
4167 query: { sid: L.env.sessionid, token: L.env.token }
4172 call({ status: 0 });
4175 this.displayStatus('warning', [
4176 E('h4', _('Device unreachable!')),
4177 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.'))
4183 confirm: function(checked, deadline, override_token) {
4185 var ts = Date.now();
4187 this.displayStatus('notice');
4190 this.confirm_auth = { token: override_token };
4192 var call = function(r, data, duration) {
4193 if (Date.now() >= deadline) {
4194 window.clearTimeout(tt);
4195 UI.prototype.changes.rollback(checked);
4198 else if (r && (r.status === 200 || r.status === 204)) {
4199 document.dispatchEvent(new CustomEvent('uci-applied'));
4201 UI.prototype.changes.setIndicator(0);
4202 UI.prototype.changes.displayStatus('notice',
4203 E('p', _('Configuration changes applied.')));
4205 window.clearTimeout(tt);
4206 window.setTimeout(function() {
4207 //UI.prototype.changes.displayStatus(false);
4208 window.location = window.location.href.split('#')[0];
4209 }, L.env.apply_display * 1000);
4214 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4215 window.setTimeout(function() {
4216 request.request(L.url('admin/uci/confirm'), {
4218 timeout: L.env.apply_timeout * 1000,
4219 query: UI.prototype.changes.confirm_auth
4220 }).then(call, call);
4224 var tick = function() {
4225 var now = Date.now();
4227 UI.prototype.changes.displayStatus('notice spinning',
4228 E('p', _('Applying configuration changes… %ds')
4229 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4231 if (now >= deadline)
4234 tt = window.setTimeout(tick, 1000 - (now - ts));
4240 /* wait a few seconds for the settings to become effective */
4241 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4245 * Apply the staged configuration changes.
4247 * Start applying staged configuration changes and open a modal dialog
4248 * with a progress indication to prevent interaction with the view
4249 * during the apply process. The modal dialog will be automatically
4250 * closed and the current view reloaded once the apply process is
4254 * @memberof LuCI.ui.changes
4255 * @param {boolean} [checked=false]
4256 * Whether to perform a checked (`true`) configuration apply or an
4257 * unchecked (`false`) one.
4259 * In case of a checked apply, the configuration changes must be
4260 * confirmed within a specific time interval, otherwise the device
4261 * will begin to roll back the changes in order to restore the previous
4264 apply: function(checked) {
4265 this.displayStatus('notice spinning',
4266 E('p', _('Starting configuration apply…')));
4268 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4270 query: { sid: L.env.sessionid, token: L.env.token }
4271 }).then(function(r) {
4272 if (r.status === (checked ? 200 : 204)) {
4273 var tok = null; try { tok = r.json(); } catch(e) {}
4274 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4275 UI.prototype.changes.confirm_auth = tok;
4277 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4279 else if (checked && r.status === 204) {
4280 UI.prototype.changes.displayStatus('notice',
4281 E('p', _('There are no changes to apply')));
4283 window.setTimeout(function() {
4284 UI.prototype.changes.displayStatus(false);
4285 }, L.env.apply_display * 1000);
4288 UI.prototype.changes.displayStatus('warning',
4289 E('p', _('Apply request failed with status <code>%h</code>')
4290 .format(r.responseText || r.statusText || r.status)));
4292 window.setTimeout(function() {
4293 UI.prototype.changes.displayStatus(false);
4294 }, L.env.apply_display * 1000);
4300 * Revert the staged configuration changes.
4302 * Start reverting staged configuration changes and open a modal dialog
4303 * with a progress indication to prevent interaction with the view
4304 * during the revert process. The modal dialog will be automatically
4305 * closed and the current view reloaded once the revert process is
4309 * @memberof LuCI.ui.changes
4311 revert: function() {
4312 this.displayStatus('notice spinning',
4313 E('p', _('Reverting configuration…')));
4315 request.request(L.url('admin/uci/revert'), {
4317 query: { sid: L.env.sessionid, token: L.env.token }
4318 }).then(function(r) {
4319 if (r.status === 200) {
4320 document.dispatchEvent(new CustomEvent('uci-reverted'));
4322 UI.prototype.changes.setIndicator(0);
4323 UI.prototype.changes.displayStatus('notice',
4324 E('p', _('Changes have been reverted.')));
4326 window.setTimeout(function() {
4327 //UI.prototype.changes.displayStatus(false);
4328 window.location = window.location.href.split('#')[0];
4329 }, L.env.apply_display * 1000);
4332 UI.prototype.changes.displayStatus('warning',
4333 E('p', _('Revert request failed with status <code>%h</code>')
4334 .format(r.statusText || r.status)));
4336 window.setTimeout(function() {
4337 UI.prototype.changes.displayStatus(false);
4338 }, L.env.apply_display * 1000);
4345 * Add validation constraints to an input element.
4347 * Compile the given type expression and optional validator function into
4348 * a validation function and bind it to the specified input element events.
4350 * @param {Node} field
4351 * The DOM input element node to bind the validation constraints to.
4353 * @param {string} type
4354 * The datatype specification to describe validation constraints.
4355 * Refer to the `LuCI.validation` class documentation for details.
4357 * @param {boolean} [optional=false]
4358 * Specifies whether empty values are allowed (`true`) or not (`false`).
4359 * If an input element is not marked optional it must not be empty,
4360 * otherwise it will be marked as invalid.
4362 * @param {function} [vfunc]
4363 * Specifies a custom validation function which is invoked after the
4364 * other validation constraints are applied. The validation must return
4365 * `true` to accept the passed value. Any other return type is converted
4366 * to a string and treated as validation error message.
4368 * @param {...string} [events=blur, keyup]
4369 * The list of events to bind. Each received event will trigger a field
4370 * validation. If omitted, the `keyup` and `blur` events are bound by
4373 * @returns {function}
4374 * Returns the compiled validator function which can be used to manually
4375 * trigger field validation or to bind it to further events.
4377 * @see LuCI.validation
4379 addValidator: function(field, type, optional, vfunc /*, ... */) {
4383 var events = this.varargs(arguments, 3);
4384 if (events.length == 0)
4385 events.push('blur', 'keyup');
4388 var cbiValidator = validation.create(field, type, optional, vfunc),
4389 validatorFn = cbiValidator.validate.bind(cbiValidator);
4391 for (var i = 0; i < events.length; i++)
4392 field.addEventListener(events[i], validatorFn);
4402 * Create a pre-bound event handler function.
4404 * Generate and bind a function suitable for use in event handlers. The
4405 * generated function automatically disables the event source element
4406 * and adds an active indication to it by adding appropriate CSS classes.
4408 * It will also await any promises returned by the wrapped function and
4409 * re-enable the source element after the promises ran to completion.
4412 * The `this` context to use for the wrapped function.
4414 * @param {function|string} fn
4415 * Specifies the function to wrap. In case of a function value, the
4416 * function is used as-is. If a string is specified instead, it is looked
4417 * up in `ctx` to obtain the function to wrap. In both cases the bound
4418 * function will be invoked with `ctx` as `this` context
4420 * @param {...*} extra_args
4421 * Any further parameter as passed as-is to the bound event handler
4422 * function in the same order as passed to `createHandlerFn()`.
4424 * @returns {function|null}
4425 * Returns the pre-bound handler function which is suitable to be passed
4426 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4427 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4428 * valid function value.
4430 createHandlerFn: function(ctx, fn /*, ... */) {
4431 if (typeof(fn) == 'string')
4434 if (typeof(fn) != 'function')
4437 var arg_offset = arguments.length - 2;
4439 return Function.prototype.bind.apply(function() {
4440 var t = arguments[arg_offset].currentTarget;
4442 t.classList.add('spinning');
4448 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4449 t.classList.remove('spinning');
4452 }, this.varargs(arguments, 2, ctx));
4456 * Load specified view class path and set it up.
4458 * Transforms the given view path into a class name, requires it
4459 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4460 * resulting class instance is a descendant of
4461 * [LuCI.view]{@link LuCI.view}.
4463 * By instantiating the view class, its corresponding contents are
4464 * rendered and included into the view area. Any runtime errors are
4465 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4467 * @param {string} path
4468 * The view path to render.
4470 * @returns {Promise<LuCI.view>}
4471 * Returns a promise resolving to the loaded view instance.
4473 instantiateView: function(path) {
4474 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4476 return L.require(className).then(function(view) {
4477 if (!(view instanceof View))
4478 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4481 }).catch(function(err) {
4482 dom.content(document.querySelector('#view'), null);
4489 AbstractElement: UIElement,
4492 Textfield: UITextfield,
4493 Textarea: UITextarea,
4494 Checkbox: UICheckbox,
4496 Dropdown: UIDropdown,
4497 DynamicList: UIDynamicList,
4498 Combobox: UICombobox,
4499 ComboButton: UIComboButton,
4500 Hiddenfield: UIHiddenfield,
4501 FileUpload: UIFileUpload