14 tooltipTimeout = null;
17 * @class AbstractElement
22 * The `AbstractElement` class serves as abstract base for the different widgets
23 * implemented by `LuCI.ui`. It provides the common logic for getting and
24 * setting values, for checking the validity state and for wiring up required
27 * UI widget instances are usually not supposed to be created by view code
28 * directly, instead they're implicitely created by `LuCI.form` when
29 * instantiating CBI forms.
31 * This class is automatically instantiated as part of `LuCI.ui`. To use it
32 * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
33 * it in external JavaScript, use `L.require("ui").then(...)` and access the
34 * `AbstractElement` property of the class instance value.
36 var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
38 * @typedef {Object} InitOptions
39 * @memberof LuCI.ui.AbstractElement
41 * @property {string} [id]
42 * Specifies the widget ID to use. It will be used as HTML `id` attribute
43 * on the toplevel widget DOM node.
45 * @property {string} [name]
46 * Specifies the widget name which is set as HTML `name` attribute on the
47 * corresponding `<input>` element.
49 * @property {boolean} [optional=true]
50 * Specifies whether the input field allows empty values.
52 * @property {string} [datatype=string]
53 * An expression describing the input data validation constraints.
54 * It defaults to `string` which will allow any value.
55 * See {@link LuCI.validation} for details on the expression format.
57 * @property {function} [validator]
58 * Specifies a custom validator function which is invoked after the
59 * standard validation constraints are checked. The function should return
60 * `true` to accept the given input value. Any other return value type is
61 * converted to a string and treated as validation error message.
63 * @property {boolean} [disabled=false]
64 * Specifies whether the widget should be rendered in disabled state
65 * (`true`) or not (`false`). Disabled widgets cannot be interacted with
66 * and are displayed in a slightly faded style.
70 * Read the current value of the input widget.
73 * @memberof LuCI.ui.AbstractElement
74 * @returns {string|string[]|null}
75 * The current value of the input element. For simple inputs like text
76 * fields or selects, the return value type will be a - possibly empty -
77 * string. Complex widgets such as `DynamicList` instances may result in
78 * an array of strings or `null` for unset values.
80 getValue: function() {
81 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
82 return this.node.value;
88 * Set the current value of the input widget.
91 * @memberof LuCI.ui.AbstractElement
92 * @param {string|string[]|null} value
93 * The value to set the input element to. For simple inputs like text
94 * fields or selects, the value should be a - possibly empty - string.
95 * Complex widgets such as `DynamicList` instances may accept string array
98 setValue: function(value) {
99 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
100 this.node.value = value;
104 * Check whether the current input value is valid.
107 * @memberof LuCI.ui.AbstractElement
109 * Returns `true` if the current input value is valid or `false` if it does
110 * not meet the validation constraints.
112 isValid: function() {
113 return (this.validState !== false);
117 * Force validation of the current input value.
119 * Usually input validation is automatically triggered by various DOM events
120 * bound to the input widget. In some cases it is required though to manually
121 * trigger validation runs, e.g. when programmatically altering values.
124 * @memberof LuCI.ui.AbstractElement
126 triggerValidation: function() {
127 if (typeof(this.vfunc) != 'function')
130 var wasValid = this.isValid();
134 return (wasValid != this.isValid());
138 * Dispatch a custom (synthetic) event in response to received events.
140 * Sets up event handlers on the given target DOM node for the given event
141 * names that dispatch a custom event of the given type to the widget root
144 * The primary purpose of this function is to set up a series of custom
145 * uniform standard events such as `widget-update`, `validation-success`,
146 * `validation-failure` etc. which are triggered by various different
147 * widget specific native DOM events.
150 * @memberof LuCI.ui.AbstractElement
151 * @param {Node} targetNode
152 * Specifies the DOM node on which the native event listeners should be
155 * @param {string} synevent
156 * The name of the custom event to dispatch to the widget root DOM node.
158 * @param {string[]} events
159 * The native DOM events for which event handlers should be registered.
161 registerEvents: function(targetNode, synevent, events) {
162 var dispatchFn = L.bind(function(ev) {
163 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
166 for (var i = 0; i < events.length; i++)
167 targetNode.addEventListener(events[i], dispatchFn);
171 * Setup listeners for native DOM events that may update the widget value.
173 * Sets up event handlers on the given target DOM node for the given event
174 * names which may cause the input value to update, such as `keyup` or
175 * `onclick` events. In contrast to change events, such update events will
176 * trigger input value validation.
179 * @memberof LuCI.ui.AbstractElement
180 * @param {Node} targetNode
181 * Specifies the DOM node on which the event listeners should be registered.
183 * @param {...string} events
184 * The DOM events for which event handlers should be registered.
186 setUpdateEvents: function(targetNode /*, ... */) {
187 var datatype = this.options.datatype,
188 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
189 validate = this.options.validate,
190 events = this.varargs(arguments, 1);
192 this.registerEvents(targetNode, 'widget-update', events);
194 if (!datatype && !validate)
197 this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
198 targetNode, datatype || 'string',
202 this.node.addEventListener('validation-success', L.bind(function(ev) {
203 this.validState = true;
206 this.node.addEventListener('validation-failure', L.bind(function(ev) {
207 this.validState = false;
212 * Setup listeners for native DOM events that may change the widget value.
214 * Sets up event handlers on the given target DOM node for the given event
215 * names which may cause the input value to change completely, such as
216 * `change` events in a select menu. In contrast to update events, such
217 * change events will not trigger input value validation but they may cause
218 * field dependencies to get re-evaluated and will mark the input widget
222 * @memberof LuCI.ui.AbstractElement
223 * @param {Node} targetNode
224 * Specifies the DOM node on which the event listeners should be registered.
226 * @param {...string} events
227 * The DOM events for which event handlers should be registered.
229 setChangeEvents: function(targetNode /*, ... */) {
230 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
232 for (var i = 1; i < arguments.length; i++)
233 targetNode.addEventListener(arguments[i], tag_changed);
235 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
239 * Render the widget, setup event listeners and return resulting markup.
242 * @memberof LuCI.ui.AbstractElement
245 * Returns a DOM Node or DocumentFragment containing the rendered
248 render: function() {}
252 * Instantiate a text input widget.
254 * @constructor Textfield
256 * @augments LuCI.ui.AbstractElement
260 * The `Textfield` class implements a standard single line text input field.
262 * UI widget instances are usually not supposed to be created by view code
263 * directly, instead they're implicitely created by `LuCI.form` when
264 * instantiating CBI forms.
266 * This class is automatically instantiated as part of `LuCI.ui`. To use it
267 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
268 * external JavaScript, use `L.require("ui").then(...)` and access the
269 * `Textfield` property of the class instance value.
271 * @param {string} [value=null]
272 * The initial input value.
274 * @param {LuCI.ui.Textfield.InitOptions} [options]
275 * Object describing the widget specific options to initialize the input.
277 var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
279 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
280 * the following properties are recognized:
282 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
283 * @memberof LuCI.ui.Textfield
285 * @property {boolean} [password=false]
286 * Specifies whether the input should be rendered as concealed password field.
288 * @property {boolean} [readonly=false]
289 * Specifies whether the input widget should be rendered readonly.
291 * @property {number} [maxlength]
292 * Specifies the HTML `maxlength` attribute to set on the corresponding
293 * `<input>` element. Note that this a legacy property that exists for
294 * compatibility reasons. It is usually better to `maxlength(N)` validation
297 * @property {string} [placeholder]
298 * Specifies the HTML `placeholder` attribute which is displayed when the
299 * corresponding `<input>` element is empty.
301 __init__: function(value, options) {
303 this.options = Object.assign({
311 var frameEl = E('div', { 'id': this.options.id });
313 if (this.options.password) {
314 frameEl.classList.add('nowrap');
315 frameEl.appendChild(E('input', {
317 'style': 'position:absolute; left:-100000px',
320 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
324 frameEl.appendChild(E('input', {
325 'id': this.options.id ? 'widget.' + this.options.id : null,
326 'name': this.options.name,
327 'type': this.options.password ? 'password' : 'text',
328 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
329 'readonly': this.options.readonly ? '' : null,
330 'disabled': this.options.disabled ? '' : null,
331 'maxlength': this.options.maxlength,
332 'placeholder': this.options.placeholder,
336 if (this.options.password)
337 frameEl.appendChild(E('button', {
338 'class': 'cbi-button cbi-button-neutral',
339 'title': _('Reveal/hide password'),
340 'aria-label': _('Reveal/hide password'),
341 'click': function(ev) {
342 var e = this.previousElementSibling;
343 e.type = (e.type === 'password') ? 'text' : 'password';
348 return this.bind(frameEl);
352 bind: function(frameEl) {
353 var inputEl = frameEl.childNodes[+!!this.options.password];
357 this.setUpdateEvents(inputEl, 'keyup', 'blur');
358 this.setChangeEvents(inputEl, 'change');
360 dom.bindClassInstance(frameEl, this);
366 getValue: function() {
367 var inputEl = this.node.childNodes[+!!this.options.password];
368 return inputEl.value;
372 setValue: function(value) {
373 var inputEl = this.node.childNodes[+!!this.options.password];
374 inputEl.value = value;
379 * Instantiate a textarea widget.
381 * @constructor Textarea
383 * @augments LuCI.ui.AbstractElement
387 * The `Textarea` class implements a multiline text area input field.
389 * UI widget instances are usually not supposed to be created by view code
390 * directly, instead they're implicitely created by `LuCI.form` when
391 * instantiating CBI forms.
393 * This class is automatically instantiated as part of `LuCI.ui`. To use it
394 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
395 * external JavaScript, use `L.require("ui").then(...)` and access the
396 * `Textarea` property of the class instance value.
398 * @param {string} [value=null]
399 * The initial input value.
401 * @param {LuCI.ui.Textarea.InitOptions} [options]
402 * Object describing the widget specific options to initialize the input.
404 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
406 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
407 * the following properties are recognized:
409 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
410 * @memberof LuCI.ui.Textarea
412 * @property {boolean} [readonly=false]
413 * Specifies whether the input widget should be rendered readonly.
415 * @property {string} [placeholder]
416 * Specifies the HTML `placeholder` attribute which is displayed when the
417 * corresponding `<textarea>` element is empty.
419 * @property {boolean} [monospace=false]
420 * Specifies whether a monospace font should be forced for the textarea
423 * @property {number} [cols]
424 * Specifies the HTML `cols` attribute to set on the corresponding
425 * `<textarea>` element.
427 * @property {number} [rows]
428 * Specifies the HTML `rows` attribute to set on the corresponding
429 * `<textarea>` element.
431 * @property {boolean} [wrap=false]
432 * Specifies whether the HTML `wrap` attribute should be set.
434 __init__: function(value, options) {
436 this.options = Object.assign({
446 var frameEl = E('div', { 'id': this.options.id }),
447 value = (this.value != null) ? String(this.value) : '';
449 frameEl.appendChild(E('textarea', {
450 'id': this.options.id ? 'widget.' + this.options.id : null,
451 'name': this.options.name,
452 'class': 'cbi-input-textarea',
453 'readonly': this.options.readonly ? '' : null,
454 'disabled': this.options.disabled ? '' : null,
455 'placeholder': this.options.placeholder,
456 'style': !this.options.cols ? 'width:100%' : null,
457 'cols': this.options.cols,
458 'rows': this.options.rows,
459 'wrap': this.options.wrap ? '' : null
462 if (this.options.monospace)
463 frameEl.firstElementChild.style.fontFamily = 'monospace';
465 return this.bind(frameEl);
469 bind: function(frameEl) {
470 var inputEl = frameEl.firstElementChild;
474 this.setUpdateEvents(inputEl, 'keyup', 'blur');
475 this.setChangeEvents(inputEl, 'change');
477 dom.bindClassInstance(frameEl, this);
483 getValue: function() {
484 return this.node.firstElementChild.value;
488 setValue: function(value) {
489 this.node.firstElementChild.value = value;
494 * Instantiate a checkbox widget.
496 * @constructor Checkbox
498 * @augments LuCI.ui.AbstractElement
502 * The `Checkbox` class implements a simple checkbox input field.
504 * UI widget instances are usually not supposed to be created by view code
505 * directly, instead they're implicitely created by `LuCI.form` when
506 * instantiating CBI forms.
508 * This class is automatically instantiated as part of `LuCI.ui`. To use it
509 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
510 * external JavaScript, use `L.require("ui").then(...)` and access the
511 * `Checkbox` property of the class instance value.
513 * @param {string} [value=null]
514 * The initial input value.
516 * @param {LuCI.ui.Checkbox.InitOptions} [options]
517 * Object describing the widget specific options to initialize the input.
519 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
521 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
522 * the following properties are recognized:
524 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
525 * @memberof LuCI.ui.Checkbox
527 * @property {string} [value_enabled=1]
528 * Specifies the value corresponding to a checked checkbox.
530 * @property {string} [value_disabled=0]
531 * Specifies the value corresponding to an unchecked checkbox.
533 * @property {string} [hiddenname]
534 * Specifies the HTML `name` attribute of the hidden input backing the
535 * checkbox. This is a legacy property existing for compatibility reasons,
536 * it is required for HTML based form submissions.
538 __init__: function(value, options) {
540 this.options = Object.assign({
548 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
549 var frameEl = E('div', {
550 'id': this.options.id,
551 'class': 'cbi-checkbox'
554 if (this.options.hiddenname)
555 frameEl.appendChild(E('input', {
557 'name': this.options.hiddenname,
561 frameEl.appendChild(E('input', {
563 'name': this.options.name,
565 'value': this.options.value_enabled,
566 'checked': (this.value == this.options.value_enabled) ? '' : null,
567 'disabled': this.options.disabled ? '' : null,
568 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
571 frameEl.appendChild(E('label', { 'for': id }));
573 return this.bind(frameEl);
577 bind: function(frameEl) {
580 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
581 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
583 dom.bindClassInstance(frameEl, this);
589 * Test whether the checkbox is currently checked.
592 * @memberof LuCI.ui.Checkbox
594 * Returns `true` when the checkbox is currently checked, otherwise `false`.
596 isChecked: function() {
597 return this.node.lastElementChild.previousElementSibling.checked;
601 getValue: function() {
602 return this.isChecked()
603 ? this.options.value_enabled
604 : this.options.value_disabled;
608 setValue: function(value) {
609 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
614 * Instantiate a select dropdown or checkbox/radiobutton group.
616 * @constructor Select
618 * @augments LuCI.ui.AbstractElement
622 * The `Select` class implements either a traditional HTML `<select>` element
623 * or a group of checkboxes or radio buttons, depending on whether multiple
624 * values are enabled or not.
626 * UI widget instances are usually not supposed to be created by view code
627 * directly, instead they're implicitely created by `LuCI.form` when
628 * instantiating CBI forms.
630 * This class is automatically instantiated as part of `LuCI.ui`. To use it
631 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
632 * external JavaScript, use `L.require("ui").then(...)` and access the
633 * `Select` property of the class instance value.
635 * @param {string|string[]} [value=null]
636 * The initial input value(s).
638 * @param {Object<string, string>} choices
639 * Object containing the selectable choices of the widget. The object keys
640 * serve as values for the different choices while the values are used as
643 * @param {LuCI.ui.Select.InitOptions} [options]
644 * Object describing the widget specific options to initialize the inputs.
646 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
648 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
649 * the following properties are recognized:
651 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
652 * @memberof LuCI.ui.Select
654 * @property {boolean} [multiple=false]
655 * Specifies whether multiple choice values may be selected.
657 * @property {string} [widget=select]
658 * Specifies the kind of widget to render. May be either `select` or
659 * `individual`. When set to `select` an HTML `<select>` element will be
660 * used, otherwise a group of checkbox or radio button elements is created,
661 * depending on the value of the `multiple` option.
663 * @property {string} [orientation=horizontal]
664 * Specifies whether checkbox / radio button groups should be rendered
665 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
668 * @property {boolean|string[]} [sort=false]
669 * Specifies if and how to sort choice values. If set to `true`, the choice
670 * values will be sorted alphabetically. If set to an array of strings, the
671 * choice sort order is derived from the array.
673 * @property {number} [size]
674 * Specifies the HTML `size` attribute to set on the `<select>` element.
675 * Only applicable to the `select` widget type.
677 * @property {string} [placeholder=-- Please choose --]
678 * Specifies a placeholder text which is displayed when no choice is
679 * selected yet. Only applicable to the `select` widget type.
681 __init__: function(value, choices, options) {
682 if (!L.isObject(choices))
685 if (!Array.isArray(value))
686 value = (value != null && value != '') ? [ value ] : [];
688 if (!options.multiple && value.length > 1)
692 this.choices = choices;
693 this.options = Object.assign({
696 orientation: 'horizontal'
699 if (this.choices.hasOwnProperty(''))
700 this.options.optional = true;
705 var frameEl = E('div', { 'id': this.options.id }),
706 keys = Object.keys(this.choices);
708 if (this.options.sort === true)
710 else if (Array.isArray(this.options.sort))
711 keys = this.options.sort;
713 if (this.options.widget == 'select') {
714 frameEl.appendChild(E('select', {
715 'id': this.options.id ? 'widget.' + this.options.id : null,
716 'name': this.options.name,
717 'size': this.options.size,
718 'class': 'cbi-input-select',
719 'multiple': this.options.multiple ? '' : null,
720 'disabled': this.options.disabled ? '' : null
723 if (this.options.optional)
724 frameEl.lastChild.appendChild(E('option', {
726 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
727 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
729 for (var i = 0; i < keys.length; i++) {
730 if (keys[i] == null || keys[i] == '')
733 frameEl.lastChild.appendChild(E('option', {
735 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
736 }, [ this.choices[keys[i]] || keys[i] ]));
740 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
742 for (var i = 0; i < keys.length; i++) {
743 frameEl.appendChild(E('label', {}, [
745 'id': this.options.id ? 'widget.' + this.options.id : null,
746 'name': this.options.id || this.options.name,
747 'type': this.options.multiple ? 'checkbox' : 'radio',
748 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
750 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
751 'disabled': this.options.disabled ? '' : null
753 this.choices[keys[i]] || keys[i]
756 if (i + 1 == this.options.size)
757 frameEl.appendChild(brEl);
761 return this.bind(frameEl);
765 bind: function(frameEl) {
768 if (this.options.widget == 'select') {
769 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
770 this.setChangeEvents(frameEl.firstChild, 'change');
773 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
774 for (var i = 0; i < radioEls.length; i++) {
775 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
776 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
780 dom.bindClassInstance(frameEl, this);
786 getValue: function() {
787 if (this.options.widget == 'select')
788 return this.node.firstChild.value;
790 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
791 for (var i = 0; i < radioEls.length; i++)
792 if (radioEls[i].checked)
793 return radioEls[i].value;
799 setValue: function(value) {
800 if (this.options.widget == 'select') {
804 for (var i = 0; i < this.node.firstChild.options.length; i++)
805 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
810 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
811 for (var i = 0; i < radioEls.length; i++)
812 radioEls[i].checked = (radioEls[i].value == value);
817 * Instantiate a rich dropdown choice widget.
819 * @constructor Dropdown
821 * @augments LuCI.ui.AbstractElement
825 * The `Dropdown` class implements a rich, stylable dropdown menu which
826 * supports non-text choice labels.
828 * UI widget instances are usually not supposed to be created by view code
829 * directly, instead they're implicitely created by `LuCI.form` when
830 * instantiating CBI forms.
832 * This class is automatically instantiated as part of `LuCI.ui`. To use it
833 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
834 * external JavaScript, use `L.require("ui").then(...)` and access the
835 * `Dropdown` property of the class instance value.
837 * @param {string|string[]} [value=null]
838 * The initial input value(s).
840 * @param {Object<string, *>} choices
841 * Object containing the selectable choices of the widget. The object keys
842 * serve as values for the different choices while the values are used as
845 * @param {LuCI.ui.Dropdown.InitOptions} [options]
846 * Object describing the widget specific options to initialize the dropdown.
848 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
850 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
851 * the following properties are recognized:
853 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
854 * @memberof LuCI.ui.Dropdown
856 * @property {boolean} [optional=true]
857 * Specifies whether the dropdown selection is optional. In contrast to
858 * other widgets, the `optional` constraint of dropdowns works differently;
859 * instead of marking the widget invalid on empty values when set to `false`,
860 * the user is not allowed to deselect all choices.
862 * For single value dropdowns that means that no empty "please select"
863 * choice is offered and for multi value dropdowns, the last selected choice
864 * may not be deselected without selecting another choice first.
866 * @property {boolean} [multiple]
867 * Specifies whether multiple choice values may be selected. It defaults
868 * to `true` when an array is passed as input value to the constructor.
870 * @property {boolean|string[]} [sort=false]
871 * Specifies if and how to sort choice values. If set to `true`, the choice
872 * values will be sorted alphabetically. If set to an array of strings, the
873 * choice sort order is derived from the array.
875 * @property {string} [select_placeholder=-- Please choose --]
876 * Specifies a placeholder text which is displayed when no choice is
879 * @property {string} [custom_placeholder=-- custom --]
880 * Specifies a placeholder text which is displayed in the text input
881 * field allowing to enter custom choice values. Only applicable if the
882 * `create` option is set to `true`.
884 * @property {boolean} [create=false]
885 * Specifies whether custom choices may be entered into the dropdown
888 * @property {string} [create_query=.create-item-input]
889 * Specifies a CSS selector expression used to find the input element
890 * which is used to enter custom choice values. This should not normally
891 * be used except by widgets derived from the Dropdown class.
893 * @property {string} [create_template=script[type="item-template"]]
894 * Specifies a CSS selector expression used to find an HTML element
895 * serving as template for newly added custom choice values.
897 * Any `{{value}}` placeholder string within the template elements text
898 * content will be replaced by the user supplied choice value, the
899 * resulting string is parsed as HTML and appended to the end of the
900 * choice list. The template markup may specify one HTML element with a
901 * `data-label-placeholder` attribute which is replaced by a matching
902 * label value from the `choices` object or with the user supplied value
903 * itself in case `choices` contains no matching choice label.
905 * If the template element is not found or if no `create_template` selector
906 * expression is specified, the default markup for newly created elements is
907 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
909 * @property {string} [create_markup]
910 * This property allows specifying the markup for custom choices directly
911 * instead of referring to a template element through CSS selectors.
913 * Apart from that it works exactly like `create_template`.
915 * @property {number} [display_items=3]
916 * Specifies the maximum amount of choice labels that should be shown in
917 * collapsed dropdown state before further selected choices are cut off.
919 * Only applicable when `multiple` is `true`.
921 * @property {number} [dropdown_items=-1]
922 * Specifies the maximum amount of choices that should be shown when the
923 * dropdown is open. If the amount of available choices exceeds this number,
924 * the dropdown area must be scrolled to reach further items.
926 * If set to `-1`, the dropdown menu will attempt to show all choice values
927 * and only resort to scrolling if the amount of choices exceeds the available
928 * screen space above and below the dropdown widget.
930 * @property {string} [placeholder]
931 * This property serves as a shortcut to set both `select_placeholder` and
932 * `custom_placeholder`. Either of these properties will fallback to
933 * `placeholder` if not specified.
935 * @property {boolean} [readonly=false]
936 * Specifies whether the custom choice input field should be rendered
937 * readonly. Only applicable when `create` is `true`.
939 * @property {number} [maxlength]
940 * Specifies the HTML `maxlength` attribute to set on the custom choice
941 * `<input>` element. Note that this a legacy property that exists for
942 * compatibility reasons. It is usually better to `maxlength(N)` validation
943 * expression. Only applicable when `create` is `true`.
945 __init__: function(value, choices, options) {
946 if (typeof(choices) != 'object')
949 if (!Array.isArray(value))
950 this.values = (value != null && value != '') ? [ value ] : [];
954 this.choices = choices;
955 this.options = Object.assign({
957 multiple: Array.isArray(value),
959 select_placeholder: _('-- Please choose --'),
960 custom_placeholder: _('-- custom --'),
964 create_query: '.create-item-input',
965 create_template: 'script[type="item-template"]'
972 'id': this.options.id,
973 'class': 'cbi-dropdown',
974 'multiple': this.options.multiple ? '' : null,
975 'optional': this.options.optional ? '' : null,
976 'disabled': this.options.disabled ? '' : null
979 var keys = Object.keys(this.choices);
981 if (this.options.sort === true)
983 else if (Array.isArray(this.options.sort))
984 keys = this.options.sort;
986 if (this.options.create)
987 for (var i = 0; i < this.values.length; i++)
988 if (!this.choices.hasOwnProperty(this.values[i]))
989 keys.push(this.values[i]);
991 for (var i = 0; i < keys.length; i++) {
992 var label = this.choices[keys[i]];
995 label = label.cloneNode(true);
997 sb.lastElementChild.appendChild(E('li', {
998 'data-value': keys[i],
999 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
1000 }, [ label || keys[i] ]));
1003 if (this.options.create) {
1004 var createEl = E('input', {
1006 'class': 'create-item-input',
1007 'readonly': this.options.readonly ? '' : null,
1008 'maxlength': this.options.maxlength,
1009 'placeholder': this.options.custom_placeholder || this.options.placeholder
1012 if (this.options.datatype || this.options.validate)
1013 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1014 true, this.options.validate, 'blur', 'keyup');
1016 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1019 if (this.options.create_markup)
1020 sb.appendChild(E('script', { type: 'item-template' },
1021 this.options.create_markup));
1023 return this.bind(sb);
1027 bind: function(sb) {
1028 var o = this.options;
1030 o.multiple = sb.hasAttribute('multiple');
1031 o.optional = sb.hasAttribute('optional');
1032 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1033 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1034 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1035 o.create_query = sb.getAttribute('item-create') || o.create_query;
1036 o.create_template = sb.getAttribute('item-template') || o.create_template;
1038 var ul = sb.querySelector('ul'),
1039 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1040 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, 'â–¾')),
1041 canary = sb.appendChild(E('div')),
1042 create = sb.querySelector(this.options.create_query),
1043 ndisplay = this.options.display_items,
1046 if (this.options.multiple) {
1047 var items = ul.querySelectorAll('li');
1049 for (var i = 0; i < items.length; i++) {
1050 this.transformItem(sb, items[i]);
1052 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1053 items[i].setAttribute('display', n++);
1057 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1058 var placeholder = E('li', { placeholder: '' },
1059 this.options.select_placeholder || this.options.placeholder);
1062 ? ul.insertBefore(placeholder, ul.firstChild)
1063 : ul.appendChild(placeholder);
1066 var items = ul.querySelectorAll('li'),
1067 sel = sb.querySelectorAll('[selected]');
1069 sel.forEach(function(s) {
1070 s.removeAttribute('selected');
1073 var s = sel[0] || items[0];
1075 s.setAttribute('selected', '');
1076 s.setAttribute('display', n++);
1082 this.saveValues(sb, ul);
1084 ul.setAttribute('tabindex', -1);
1085 sb.setAttribute('tabindex', 0);
1088 sb.setAttribute('more', '')
1090 sb.removeAttribute('more');
1092 if (ndisplay == this.options.display_items)
1093 sb.setAttribute('empty', '')
1095 sb.removeAttribute('empty');
1097 dom.content(more, (ndisplay == this.options.display_items)
1098 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1101 sb.addEventListener('click', this.handleClick.bind(this));
1102 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1103 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1104 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1106 if ('ontouchstart' in window) {
1107 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1108 window.addEventListener('touchstart', this.closeAllDropdowns);
1111 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1112 sb.addEventListener('focus', this.handleFocus.bind(this));
1114 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1116 window.addEventListener('mouseover', this.setFocus);
1117 window.addEventListener('click', this.closeAllDropdowns);
1121 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1122 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1123 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1125 var li = findParent(create, 'li');
1127 li.setAttribute('unselectable', '');
1128 li.addEventListener('click', this.handleCreateClick.bind(this));
1133 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1134 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1136 dom.bindClassInstance(sb, this);
1142 openDropdown: function(sb) {
1143 var st = window.getComputedStyle(sb, null),
1144 ul = sb.querySelector('ul'),
1145 li = ul.querySelectorAll('li'),
1146 fl = findParent(sb, '.cbi-value-field'),
1147 sel = ul.querySelector('[selected]'),
1148 rect = sb.getBoundingClientRect(),
1149 items = Math.min(this.options.dropdown_items, li.length);
1151 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1152 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1155 sb.setAttribute('open', '');
1157 var pv = ul.cloneNode(true);
1158 pv.classList.add('preview');
1161 fl.classList.add('cbi-dropdown-open');
1163 if ('ontouchstart' in window) {
1164 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1165 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1168 ul.style.top = sb.offsetHeight + 'px';
1169 ul.style.left = -rect.left + 'px';
1170 ul.style.right = (rect.right - vpWidth) + 'px';
1171 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1172 ul.style.WebkitOverflowScrolling = 'touch';
1174 function getScrollParent(element) {
1175 var parent = element,
1176 style = getComputedStyle(element),
1177 excludeStaticParent = (style.position === 'absolute');
1179 if (style.position === 'fixed')
1180 return document.body;
1182 while ((parent = parent.parentElement) != null) {
1183 style = getComputedStyle(parent);
1185 if (excludeStaticParent && style.position === 'static')
1188 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1192 return document.body;
1195 var scrollParent = getScrollParent(sb),
1196 scrollFrom = scrollParent.scrollTop,
1197 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1199 var scrollStep = function(timestamp) {
1202 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1205 var duration = Math.max(timestamp - start, 1);
1206 if (duration < 100) {
1207 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1208 window.requestAnimationFrame(scrollStep);
1211 scrollParent.scrollTop = scrollTo;
1215 window.requestAnimationFrame(scrollStep);
1218 ul.style.maxHeight = '1px';
1219 ul.style.top = ul.style.bottom = '';
1221 window.requestAnimationFrame(function() {
1222 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1224 spaceAbove = rect.top,
1225 spaceBelow = window.innerHeight - rect.height - rect.top;
1227 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1228 fullHeight += li[i].getBoundingClientRect().height;
1230 if (fullHeight <= spaceBelow) {
1231 ul.style.top = rect.height + 'px';
1232 ul.style.maxHeight = spaceBelow + 'px';
1234 else if (fullHeight <= spaceAbove) {
1235 ul.style.bottom = rect.height + 'px';
1236 ul.style.maxHeight = spaceAbove + 'px';
1238 else if (spaceBelow >= spaceAbove) {
1239 ul.style.top = rect.height + 'px';
1240 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1243 ul.style.bottom = rect.height + 'px';
1244 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1247 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1251 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1252 for (var i = 0; i < cboxes.length; i++) {
1253 cboxes[i].checked = true;
1254 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1257 ul.classList.add('dropdown');
1259 sb.insertBefore(pv, ul.nextElementSibling);
1261 li.forEach(function(l) {
1262 l.setAttribute('tabindex', 0);
1265 sb.lastElementChild.setAttribute('tabindex', 0);
1267 this.setFocus(sb, sel || li[0], true);
1271 closeDropdown: function(sb, no_focus) {
1272 if (!sb.hasAttribute('open'))
1275 var pv = sb.querySelector('ul.preview'),
1276 ul = sb.querySelector('ul.dropdown'),
1277 li = ul.querySelectorAll('li'),
1278 fl = findParent(sb, '.cbi-value-field');
1280 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1281 sb.lastElementChild.removeAttribute('tabindex');
1284 sb.removeAttribute('open');
1285 sb.style.width = sb.style.height = '';
1287 ul.classList.remove('dropdown');
1288 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1291 fl.classList.remove('cbi-dropdown-open');
1294 this.setFocus(sb, sb);
1296 this.saveValues(sb, ul);
1300 toggleItem: function(sb, li, force_state) {
1301 if (li.hasAttribute('unselectable'))
1304 if (this.options.multiple) {
1305 var cbox = li.querySelector('input[type="checkbox"]'),
1306 items = li.parentNode.querySelectorAll('li'),
1307 label = sb.querySelector('ul.preview'),
1308 sel = li.parentNode.querySelectorAll('[selected]').length,
1309 more = sb.querySelector('.more'),
1310 ndisplay = this.options.display_items,
1313 if (li.hasAttribute('selected')) {
1314 if (force_state !== true) {
1315 if (sel > 1 || this.options.optional) {
1316 li.removeAttribute('selected');
1317 cbox.checked = cbox.disabled = false;
1321 cbox.disabled = true;
1326 if (force_state !== false) {
1327 li.setAttribute('selected', '');
1328 cbox.checked = true;
1329 cbox.disabled = false;
1334 while (label && label.firstElementChild)
1335 label.removeChild(label.firstElementChild);
1337 for (var i = 0; i < items.length; i++) {
1338 items[i].removeAttribute('display');
1339 if (items[i].hasAttribute('selected')) {
1340 if (ndisplay-- > 0) {
1341 items[i].setAttribute('display', n++);
1343 label.appendChild(items[i].cloneNode(true));
1345 var c = items[i].querySelector('input[type="checkbox"]');
1347 c.disabled = (sel == 1 && !this.options.optional);
1352 sb.setAttribute('more', '');
1354 sb.removeAttribute('more');
1356 if (ndisplay === this.options.display_items)
1357 sb.setAttribute('empty', '');
1359 sb.removeAttribute('empty');
1361 dom.content(more, (ndisplay === this.options.display_items)
1362 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1365 var sel = li.parentNode.querySelector('[selected]');
1367 sel.removeAttribute('display');
1368 sel.removeAttribute('selected');
1371 li.setAttribute('display', 0);
1372 li.setAttribute('selected', '');
1374 this.closeDropdown(sb, true);
1377 this.saveValues(sb, li.parentNode);
1381 transformItem: function(sb, li) {
1382 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1385 while (li.firstChild)
1386 label.appendChild(li.firstChild);
1388 li.appendChild(cbox);
1389 li.appendChild(label);
1393 saveValues: function(sb, ul) {
1394 var sel = ul.querySelectorAll('li[selected]'),
1395 div = sb.lastElementChild,
1396 name = this.options.name,
1400 while (div.lastElementChild)
1401 div.removeChild(div.lastElementChild);
1403 sel.forEach(function (s) {
1404 if (s.hasAttribute('placeholder'))
1409 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1413 div.appendChild(E('input', {
1421 strval += strval.length ? ' ' + v.value : v.value;
1429 if (this.options.multiple)
1430 detail.values = values;
1432 detail.value = values.length ? values[0] : null;
1436 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1443 setValues: function(sb, values) {
1444 var ul = sb.querySelector('ul');
1446 if (this.options.create) {
1447 for (var value in values) {
1448 this.createItems(sb, value);
1450 if (!this.options.multiple)
1455 if (this.options.multiple) {
1456 var lis = ul.querySelectorAll('li[data-value]');
1457 for (var i = 0; i < lis.length; i++) {
1458 var value = lis[i].getAttribute('data-value');
1459 if (values === null || !(value in values))
1460 this.toggleItem(sb, lis[i], false);
1462 this.toggleItem(sb, lis[i], true);
1466 var ph = ul.querySelector('li[placeholder]');
1468 this.toggleItem(sb, ph);
1470 var lis = ul.querySelectorAll('li[data-value]');
1471 for (var i = 0; i < lis.length; i++) {
1472 var value = lis[i].getAttribute('data-value');
1473 if (values !== null && (value in values))
1474 this.toggleItem(sb, lis[i]);
1480 setFocus: function(sb, elem, scroll) {
1481 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1484 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1487 document.querySelectorAll('.focus').forEach(function(e) {
1488 if (!matchesElem(e, 'input')) {
1489 e.classList.remove('focus');
1496 elem.classList.add('focus');
1499 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1504 createChoiceElement: function(sb, value, label) {
1505 var tpl = sb.querySelector(this.options.create_template),
1509 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1511 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1513 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1514 placeholder = new_item.querySelector('[data-label-placeholder]');
1517 var content = E('span', {}, label || this.choices[value] || [ value ]);
1519 while (content.firstChild)
1520 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1522 placeholder.parentNode.removeChild(placeholder);
1525 if (this.options.multiple)
1526 this.transformItem(sb, new_item);
1532 createItems: function(sb, value) {
1534 val = (value || '').trim(),
1535 ul = sb.querySelector('ul');
1537 if (!sbox.options.multiple)
1538 val = val.length ? [ val ] : [];
1540 val = val.length ? val.split(/\s+/) : [];
1542 val.forEach(function(item) {
1543 var new_item = null;
1545 ul.childNodes.forEach(function(li) {
1546 if (li.getAttribute && li.getAttribute('data-value') === item)
1551 new_item = sbox.createChoiceElement(sb, item);
1553 if (!sbox.options.multiple) {
1554 var old = ul.querySelector('li[created]');
1556 ul.removeChild(old);
1558 new_item.setAttribute('created', '');
1561 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1564 sbox.toggleItem(sb, new_item, true);
1565 sbox.setFocus(sb, new_item, true);
1570 * Remove all existing choices from the dropdown menu.
1572 * This function removes all preexisting dropdown choices from the widget,
1573 * keeping only choices currently being selected unless `reset_values` is
1574 * given, in which case all choices and deselected and removed.
1577 * @memberof LuCI.ui.Dropdown
1578 * @param {boolean} [reset_value=false]
1579 * If set to `true`, deselect and remove selected choices as well instead
1582 clearChoices: function(reset_value) {
1583 var ul = this.node.querySelector('ul'),
1584 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1585 len = lis.length - (this.options.create ? 1 : 0),
1586 val = reset_value ? null : this.getValue();
1588 for (var i = 0; i < len; i++) {
1589 var lival = lis[i].getAttribute('data-value');
1591 (!this.options.multiple && val != lival) ||
1592 (this.options.multiple && val.indexOf(lival) == -1))
1593 ul.removeChild(lis[i]);
1597 this.setValues(this.node, {});
1601 * Add new choices to the dropdown menu.
1603 * This function adds further choices to an existing dropdown menu,
1604 * ignoring choice values which are already present.
1607 * @memberof LuCI.ui.Dropdown
1608 * @param {string[]} values
1609 * The choice values to add to the dropdown widget.
1611 * @param {Object<string, *>} labels
1612 * The choice label values to use when adding dropdown choices. If no
1613 * label is found for a particular choice value, the value itself is used
1614 * as label text. Choice labels may be any valid value accepted by
1615 * {@link LuCI.dom#content}.
1617 addChoices: function(values, labels) {
1619 ul = sb.querySelector('ul'),
1620 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1622 if (!Array.isArray(values))
1623 values = L.toArray(values);
1625 if (!L.isObject(labels))
1628 for (var i = 0; i < values.length; i++) {
1631 for (var j = 0; j < lis.length; j++) {
1632 if (lis[j].getAttribute('data-value') === values[i]) {
1642 this.createChoiceElement(sb, values[i], labels[values[i]]),
1643 ul.lastElementChild);
1648 * Close all open dropdown widgets in the current document.
1650 closeAllDropdowns: function() {
1651 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1652 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1657 handleClick: function(ev) {
1658 var sb = ev.currentTarget;
1660 if (!sb.hasAttribute('open')) {
1661 if (!matchesElem(ev.target, 'input'))
1662 this.openDropdown(sb);
1665 var li = findParent(ev.target, 'li');
1666 if (li && li.parentNode.classList.contains('dropdown'))
1667 this.toggleItem(sb, li);
1668 else if (li && li.parentNode.classList.contains('preview'))
1669 this.closeDropdown(sb);
1670 else if (matchesElem(ev.target, 'span.open, span.more'))
1671 this.closeDropdown(sb);
1674 ev.preventDefault();
1675 ev.stopPropagation();
1679 handleKeydown: function(ev) {
1680 var sb = ev.currentTarget;
1682 if (matchesElem(ev.target, 'input'))
1685 if (!sb.hasAttribute('open')) {
1686 switch (ev.keyCode) {
1691 this.openDropdown(sb);
1692 ev.preventDefault();
1696 var active = findParent(document.activeElement, 'li');
1698 switch (ev.keyCode) {
1700 this.closeDropdown(sb);
1705 if (!active.hasAttribute('selected'))
1706 this.toggleItem(sb, active);
1707 this.closeDropdown(sb);
1708 ev.preventDefault();
1714 this.toggleItem(sb, active);
1715 ev.preventDefault();
1720 if (active && active.previousElementSibling) {
1721 this.setFocus(sb, active.previousElementSibling);
1722 ev.preventDefault();
1727 if (active && active.nextElementSibling) {
1728 this.setFocus(sb, active.nextElementSibling);
1729 ev.preventDefault();
1737 handleDropdownClose: function(ev) {
1738 var sb = ev.currentTarget;
1740 this.closeDropdown(sb, true);
1744 handleDropdownSelect: function(ev) {
1745 var sb = ev.currentTarget,
1746 li = findParent(ev.target, 'li');
1751 this.toggleItem(sb, li);
1752 this.closeDropdown(sb, true);
1756 handleMouseover: function(ev) {
1757 var sb = ev.currentTarget;
1759 if (!sb.hasAttribute('open'))
1762 var li = findParent(ev.target, 'li');
1764 if (li && li.parentNode.classList.contains('dropdown'))
1765 this.setFocus(sb, li);
1769 handleFocus: function(ev) {
1770 var sb = ev.currentTarget;
1772 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1773 if (s !== sb || sb.hasAttribute('open'))
1774 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1779 handleCanaryFocus: function(ev) {
1780 this.closeDropdown(ev.currentTarget.parentNode);
1784 handleCreateKeydown: function(ev) {
1785 var input = ev.currentTarget,
1786 sb = findParent(input, '.cbi-dropdown');
1788 switch (ev.keyCode) {
1790 ev.preventDefault();
1792 if (input.classList.contains('cbi-input-invalid'))
1795 this.createItems(sb, input.value);
1803 handleCreateFocus: function(ev) {
1804 var input = ev.currentTarget,
1805 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1806 sb = findParent(input, '.cbi-dropdown');
1809 cbox.checked = true;
1811 sb.setAttribute('locked-in', '');
1815 handleCreateBlur: function(ev) {
1816 var input = ev.currentTarget,
1817 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1818 sb = findParent(input, '.cbi-dropdown');
1821 cbox.checked = false;
1823 sb.removeAttribute('locked-in');
1827 handleCreateClick: function(ev) {
1828 ev.currentTarget.querySelector(this.options.create_query).focus();
1832 setValue: function(values) {
1833 if (this.options.multiple) {
1834 if (!Array.isArray(values))
1835 values = (values != null && values != '') ? [ values ] : [];
1839 for (var i = 0; i < values.length; i++)
1840 v[values[i]] = true;
1842 this.setValues(this.node, v);
1847 if (values != null) {
1848 if (Array.isArray(values))
1849 v[values[0]] = true;
1854 this.setValues(this.node, v);
1859 getValue: function() {
1860 var div = this.node.lastElementChild,
1861 h = div.querySelectorAll('input[type="hidden"]'),
1864 for (var i = 0; i < h.length; i++)
1867 return this.options.multiple ? v : v[0];
1872 * Instantiate a rich dropdown choice widget allowing custom values.
1874 * @constructor Combobox
1876 * @augments LuCI.ui.Dropdown
1880 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1881 * to enter custom values. Historically, comboboxes used to be a dedicated
1882 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1883 * with a set of enforced default properties for easier instantiation.
1885 * UI widget instances are usually not supposed to be created by view code
1886 * directly, instead they're implicitely created by `LuCI.form` when
1887 * instantiating CBI forms.
1889 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1890 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1891 * external JavaScript, use `L.require("ui").then(...)` and access the
1892 * `Combobox` property of the class instance value.
1894 * @param {string|string[]} [value=null]
1895 * The initial input value(s).
1897 * @param {Object<string, *>} choices
1898 * Object containing the selectable choices of the widget. The object keys
1899 * serve as values for the different choices while the values are used as
1902 * @param {LuCI.ui.Combobox.InitOptions} [options]
1903 * Object describing the widget specific options to initialize the dropdown.
1905 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1907 * Comboboxes support the same properties as
1908 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1909 * specific values for the following properties:
1911 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1912 * @memberof LuCI.ui.Combobox
1914 * @property {boolean} multiple=false
1915 * Since Comboboxes never allow selecting multiple values, this property
1916 * is forcibly set to `false`.
1918 * @property {boolean} create=true
1919 * Since Comboboxes always allow custom choice values, this property is
1920 * forcibly set to `true`.
1922 * @property {boolean} optional=true
1923 * Since Comboboxes are always optional, this property is forcibly set to
1926 __init__: function(value, choices, options) {
1927 this.super('__init__', [ value, choices, Object.assign({
1928 select_placeholder: _('-- Please choose --'),
1929 custom_placeholder: _('-- custom --'),
1941 * Instantiate a combo button widget offering multiple action choices.
1943 * @constructor ComboButton
1945 * @augments LuCI.ui.Dropdown
1949 * The `ComboButton` class implements a button element which can be expanded
1950 * into a dropdown to chose from a set of different action choices.
1952 * UI widget instances are usually not supposed to be created by view code
1953 * directly, instead they're implicitely created by `LuCI.form` when
1954 * instantiating CBI forms.
1956 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1957 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1958 * external JavaScript, use `L.require("ui").then(...)` and access the
1959 * `ComboButton` property of the class instance value.
1961 * @param {string|string[]} [value=null]
1962 * The initial input value(s).
1964 * @param {Object<string, *>} choices
1965 * Object containing the selectable choices of the widget. The object keys
1966 * serve as values for the different choices while the values are used as
1969 * @param {LuCI.ui.ComboButton.InitOptions} [options]
1970 * Object describing the widget specific options to initialize the button.
1972 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1974 * ComboButtons support the same properties as
1975 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1976 * specific values for some properties and add aditional button specific
1979 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1980 * @memberof LuCI.ui.ComboButton
1982 * @property {boolean} multiple=false
1983 * Since ComboButtons never allow selecting multiple actions, this property
1984 * is forcibly set to `false`.
1986 * @property {boolean} create=false
1987 * Since ComboButtons never allow creating custom choices, this property
1988 * is forcibly set to `false`.
1990 * @property {boolean} optional=false
1991 * Since ComboButtons must always select one action, this property is
1992 * forcibly set to `false`.
1994 * @property {Object<string, string>} [classes]
1995 * Specifies a mapping of choice values to CSS class names. If an action
1996 * choice is selected by the user and if a corresponding entry exists in
1997 * the `classes` object, the class names corresponding to the selected
1998 * value are set on the button element.
2000 * This is useful to apply different button styles, such as colors, to the
2001 * combined button depending on the selected action.
2003 * @property {function} [click]
2004 * Specifies a handler function to invoke when the user clicks the button.
2005 * This function will be called with the button DOM node as `this` context
2006 * and receive the DOM click event as first as well as the selected action
2007 * choice value as second argument.
2009 __init__: function(value, choices, options) {
2010 this.super('__init__', [ value, choices, Object.assign({
2020 render: function(/* ... */) {
2021 var node = UIDropdown.prototype.render.apply(this, arguments),
2022 val = this.getValue();
2024 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2025 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2031 handleClick: function(ev) {
2032 var sb = ev.currentTarget,
2035 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2036 return UIDropdown.prototype.handleClick.apply(this, arguments);
2038 if (this.options.click)
2039 return this.options.click.call(sb, ev, this.getValue());
2043 toggleItem: function(sb /*, ... */) {
2044 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2045 val = this.getValue();
2047 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2048 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2050 sb.setAttribute('class', 'cbi-dropdown');
2057 * Instantiate a dynamic list widget.
2059 * @constructor DynamicList
2061 * @augments LuCI.ui.AbstractElement
2065 * The `DynamicList` class implements a widget which allows the user to specify
2066 * an arbitrary amount of input values, either from free formed text input or
2067 * from a set of predefined choices.
2069 * UI widget instances are usually not supposed to be created by view code
2070 * directly, instead they're implicitely created by `LuCI.form` when
2071 * instantiating CBI forms.
2073 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2074 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2075 * external JavaScript, use `L.require("ui").then(...)` and access the
2076 * `DynamicList` property of the class instance value.
2078 * @param {string|string[]} [value=null]
2079 * The initial input value(s).
2081 * @param {Object<string, *>} [choices]
2082 * Object containing the selectable choices of the widget. The object keys
2083 * serve as values for the different choices while the values are used as
2084 * choice labels. If omitted, no default choices are presented to the user,
2085 * instead a plain text input field is rendered allowing the user to add
2086 * arbitrary values to the dynamic list.
2088 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2089 * Object describing the widget specific options to initialize the dynamic list.
2091 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2093 * In case choices are passed to the dynamic list contructor, the widget
2094 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2095 * but enforces specific values for some dropdown properties.
2097 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2098 * @memberof LuCI.ui.DynamicList
2100 * @property {boolean} multiple=false
2101 * Since dynamic lists never allow selecting multiple choices when adding
2102 * another list item, this property is forcibly set to `false`.
2104 * @property {boolean} optional=true
2105 * Since dynamic lists use an embedded dropdown to present a list of
2106 * predefined choice values, the dropdown must be made optional to allow
2107 * it to remain unselected.
2109 __init__: function(values, choices, options) {
2110 if (!Array.isArray(values))
2111 values = (values != null && values != '') ? [ values ] : [];
2113 if (typeof(choices) != 'object')
2116 this.values = values;
2117 this.choices = choices;
2118 this.options = Object.assign({}, options, {
2125 render: function() {
2127 'id': this.options.id,
2128 'class': 'cbi-dynlist'
2129 }, E('div', { 'class': 'add-item' }));
2132 if (this.options.placeholder != null)
2133 this.options.select_placeholder = this.options.placeholder;
2135 var cbox = new UICombobox(null, this.choices, this.options);
2137 dl.lastElementChild.appendChild(cbox.render());
2140 var inputEl = E('input', {
2141 'id': this.options.id ? 'widget.' + this.options.id : null,
2143 'class': 'cbi-input-text',
2144 'placeholder': this.options.placeholder,
2145 'disabled': this.options.disabled ? '' : null
2148 dl.lastElementChild.appendChild(inputEl);
2149 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2151 if (this.options.datatype || this.options.validate)
2152 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2153 true, this.options.validate, 'blur', 'keyup');
2156 for (var i = 0; i < this.values.length; i++) {
2157 var label = this.choices ? this.choices[this.values[i]] : null;
2159 if (dom.elem(label))
2160 label = label.cloneNode(true);
2162 this.addItem(dl, this.values[i], label);
2165 return this.bind(dl);
2169 bind: function(dl) {
2170 dl.addEventListener('click', L.bind(this.handleClick, this));
2171 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2172 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2176 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2177 this.setChangeEvents(dl, 'cbi-dynlist-change');
2179 dom.bindClassInstance(dl, this);
2185 addItem: function(dl, value, text, flash) {
2187 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2188 E('span', {}, [ text || value ]),
2191 'name': this.options.name,
2192 'value': value })]);
2194 dl.querySelectorAll('.item').forEach(function(item) {
2198 var hidden = item.querySelector('input[type="hidden"]');
2200 if (hidden && hidden.parentNode !== item)
2203 if (hidden && hidden.value === value)
2208 var ai = dl.querySelector('.add-item');
2209 ai.parentNode.insertBefore(new_item, ai);
2212 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2224 removeItem: function(dl, item) {
2225 var value = item.querySelector('input[type="hidden"]').value;
2226 var sb = dl.querySelector('.cbi-dropdown');
2228 sb.querySelectorAll('ul > li').forEach(function(li) {
2229 if (li.getAttribute('data-value') === value) {
2230 if (li.hasAttribute('dynlistcustom'))
2231 li.parentNode.removeChild(li);
2233 li.removeAttribute('unselectable');
2237 item.parentNode.removeChild(item);
2239 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2251 handleClick: function(ev) {
2252 var dl = ev.currentTarget,
2253 item = findParent(ev.target, '.item');
2255 if (this.options.disabled)
2259 this.removeItem(dl, item);
2261 else if (matchesElem(ev.target, '.cbi-button-add')) {
2262 var input = ev.target.previousElementSibling;
2263 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2264 this.addItem(dl, input.value, null, true);
2271 handleDropdownChange: function(ev) {
2272 var dl = ev.currentTarget,
2273 sbIn = ev.detail.instance,
2274 sbEl = ev.detail.element,
2275 sbVal = ev.detail.value;
2280 sbIn.setValues(sbEl, null);
2281 sbVal.element.setAttribute('unselectable', '');
2283 if (sbVal.element.hasAttribute('created')) {
2284 sbVal.element.removeAttribute('created');
2285 sbVal.element.setAttribute('dynlistcustom', '');
2288 var label = sbVal.text;
2290 if (sbVal.element) {
2293 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2294 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2297 this.addItem(dl, sbVal.value, label, true);
2301 handleKeydown: function(ev) {
2302 var dl = ev.currentTarget,
2303 item = findParent(ev.target, '.item');
2306 switch (ev.keyCode) {
2307 case 8: /* backspace */
2308 if (item.previousElementSibling)
2309 item.previousElementSibling.focus();
2311 this.removeItem(dl, item);
2314 case 46: /* delete */
2315 if (item.nextElementSibling) {
2316 if (item.nextElementSibling.classList.contains('item'))
2317 item.nextElementSibling.focus();
2319 item.nextElementSibling.firstElementChild.focus();
2322 this.removeItem(dl, item);
2326 else if (matchesElem(ev.target, '.cbi-input-text')) {
2327 switch (ev.keyCode) {
2328 case 13: /* enter */
2329 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2330 this.addItem(dl, ev.target.value, null, true);
2331 ev.target.value = '';
2336 ev.preventDefault();
2343 getValue: function() {
2344 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2345 input = this.node.querySelector('.add-item > input[type="text"]'),
2348 for (var i = 0; i < items.length; i++)
2349 v.push(items[i].value);
2351 if (input && input.value != null && input.value.match(/\S/) &&
2352 input.classList.contains('cbi-input-invalid') == false &&
2353 v.filter(function(s) { return s == input.value }).length == 0)
2354 v.push(input.value);
2360 setValue: function(values) {
2361 if (!Array.isArray(values))
2362 values = (values != null && values != '') ? [ values ] : [];
2364 var items = this.node.querySelectorAll('.item');
2366 for (var i = 0; i < items.length; i++)
2367 if (items[i].parentNode === this.node)
2368 this.removeItem(this.node, items[i]);
2370 for (var i = 0; i < values.length; i++)
2371 this.addItem(this.node, values[i],
2372 this.choices ? this.choices[values[i]] : null);
2376 * Add new suggested choices to the dynamic list.
2378 * This function adds further choices to an existing dynamic list,
2379 * ignoring choice values which are already present.
2382 * @memberof LuCI.ui.DynamicList
2383 * @param {string[]} values
2384 * The choice values to add to the dynamic lists suggestion dropdown.
2386 * @param {Object<string, *>} labels
2387 * The choice label values to use when adding suggested choices. If no
2388 * label is found for a particular choice value, the value itself is used
2389 * as label text. Choice labels may be any valid value accepted by
2390 * {@link LuCI.dom#content}.
2392 addChoices: function(values, labels) {
2393 var dl = this.node.lastElementChild.firstElementChild;
2394 dom.callClassMethod(dl, 'addChoices', values, labels);
2398 * Remove all existing choices from the dynamic list.
2400 * This function removes all preexisting suggested choices from the widget.
2403 * @memberof LuCI.ui.DynamicList
2405 clearChoices: function() {
2406 var dl = this.node.lastElementChild.firstElementChild;
2407 dom.callClassMethod(dl, 'clearChoices');
2412 * Instantiate a hidden input field widget.
2414 * @constructor Hiddenfield
2416 * @augments LuCI.ui.AbstractElement
2420 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2421 * which allows to store form data without exposing it to the user.
2423 * UI widget instances are usually not supposed to be created by view code
2424 * directly, instead they're implicitely created by `LuCI.form` when
2425 * instantiating CBI forms.
2427 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2428 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2429 * external JavaScript, use `L.require("ui").then(...)` and access the
2430 * `Hiddenfield` property of the class instance value.
2432 * @param {string|string[]} [value=null]
2433 * The initial input value.
2435 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2436 * Object describing the widget specific options to initialize the hidden input.
2438 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2439 __init__: function(value, options) {
2441 this.options = Object.assign({
2447 render: function() {
2448 var hiddenEl = E('input', {
2449 'id': this.options.id,
2454 return this.bind(hiddenEl);
2458 bind: function(hiddenEl) {
2459 this.node = hiddenEl;
2461 dom.bindClassInstance(hiddenEl, this);
2467 getValue: function() {
2468 return this.node.value;
2472 setValue: function(value) {
2473 this.node.value = value;
2478 * Instantiate a file upload widget.
2480 * @constructor FileUpload
2482 * @augments LuCI.ui.AbstractElement
2486 * The `FileUpload` class implements a widget which allows the user to upload,
2487 * browse, select and delete files beneath a predefined remote directory.
2489 * UI widget instances are usually not supposed to be created by view code
2490 * directly, instead they're implicitely created by `LuCI.form` when
2491 * instantiating CBI forms.
2493 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2494 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2495 * external JavaScript, use `L.require("ui").then(...)` and access the
2496 * `FileUpload` property of the class instance value.
2498 * @param {string|string[]} [value=null]
2499 * The initial input value.
2501 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2502 * Object describing the widget specific options to initialize the file
2505 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2507 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2508 * the following properties are recognized:
2510 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2511 * @memberof LuCI.ui.FileUpload
2513 * @property {boolean} [show_hidden=false]
2514 * Specifies whether hidden files should be displayed when browsing remote
2515 * files. Note that this is not a security feature, hidden files are always
2516 * present in the remote file listings received, this option merely controls
2517 * whether they're displayed or not.
2519 * @property {boolean} [enable_upload=true]
2520 * Specifies whether the widget allows the user to upload files. If set to
2521 * `false`, only existing files may be selected. Note that this is not a
2522 * security feature. Whether file upload requests are accepted remotely
2523 * depends on the ACL setup for the current session. This option merely
2524 * controls whether the upload controls are rendered or not.
2526 * @property {boolean} [enable_remove=true]
2527 * Specifies whether the widget allows the user to delete remove files.
2528 * If set to `false`, existing files may not be removed. Note that this is
2529 * not a security feature. Whether file delete requests are accepted
2530 * remotely depends on the ACL setup for the current session. This option
2531 * merely controls whether the file remove controls are rendered or not.
2533 * @property {string} [root_directory=/etc/luci-uploads]
2534 * Specifies the remote directory the upload and file browsing actions take
2535 * place in. Browsing to directories outside of the root directory is
2536 * prevented by the widget. Note that this is not a security feature.
2537 * Whether remote directories are browseable or not solely depends on the
2538 * ACL setup for the current session.
2540 __init__: function(value, options) {
2542 this.options = Object.assign({
2544 enable_upload: true,
2545 enable_remove: true,
2546 root_directory: '/etc/luci-uploads'
2551 bind: function(browserEl) {
2552 this.node = browserEl;
2554 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2555 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2557 dom.bindClassInstance(browserEl, this);
2563 render: function() {
2564 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2567 if (L.isObject(stat) && stat.type != 'directory')
2570 if (this.stat != null)
2571 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2572 else if (this.value != null)
2573 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2575 label = [ _('Select file…') ];
2577 return this.bind(E('div', { 'id': this.options.id }, [
2580 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2581 'disabled': this.options.disabled ? '' : null
2584 'class': 'cbi-filebrowser'
2588 'name': this.options.name,
2596 truncatePath: function(path) {
2597 if (path.length > 50)
2598 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2604 iconForType: function(type) {
2608 'src': L.resource('cbi/link.gif'),
2609 'title': _('Symbolic link'),
2615 'src': L.resource('cbi/folder.gif'),
2616 'title': _('Directory'),
2622 'src': L.resource('cbi/file.gif'),
2630 canonicalizePath: function(path) {
2631 return path.replace(/\/{2,}/, '/')
2632 .replace(/\/\.(\/|$)/g, '/')
2633 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2634 .replace(/\/$/, '');
2638 splitPath: function(path) {
2639 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2640 cpath = this.canonicalizePath(path || '/');
2642 if (cpath.length <= croot.length)
2645 if (cpath.charAt(croot.length) != '/')
2648 var parts = cpath.substring(croot.length + 1).split(/\//);
2650 parts.unshift(croot);
2656 handleUpload: function(path, list, ev) {
2657 var form = ev.target.parentNode,
2658 fileinput = form.querySelector('input[type="file"]'),
2659 nameinput = form.querySelector('input[type="text"]'),
2660 filename = (nameinput.value != null ? nameinput.value : '').trim();
2662 ev.preventDefault();
2664 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2667 var existing = list.filter(function(e) { return e.name == filename })[0];
2669 if (existing != null && existing.type == 'directory')
2670 return alert(_('A directory with the same name already exists.'));
2671 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2674 var data = new FormData();
2676 data.append('sessionid', L.env.sessionid);
2677 data.append('filename', path + '/' + filename);
2678 data.append('filedata', fileinput.files[0]);
2680 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2681 progress: L.bind(function(btn, ev) {
2682 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2684 }).then(L.bind(function(path, ev, res) {
2685 var reply = res.json();
2687 if (L.isObject(reply) && reply.failure)
2688 alert(_('Upload request failed: %s').format(reply.message));
2690 return this.handleSelect(path, null, ev);
2691 }, this, path, ev));
2695 handleDelete: function(path, fileStat, ev) {
2696 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2697 name = path.replace(/^.+\//, ''),
2700 ev.preventDefault();
2702 if (fileStat.type == 'directory')
2703 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2705 msg = _('Do you really want to delete "%s" ?').format(name);
2708 var button = this.node.firstElementChild,
2709 hidden = this.node.lastElementChild;
2711 if (path == hidden.value) {
2712 dom.content(button, _('Select file…'));
2716 return fs.remove(path).then(L.bind(function(parent, ev) {
2717 return this.handleSelect(parent, null, ev);
2718 }, this, parent, ev)).catch(function(err) {
2719 alert(_('Delete request failed: %s').format(err.message));
2725 renderUpload: function(path, list) {
2726 if (!this.options.enable_upload)
2732 'class': 'btn cbi-button-positive',
2733 'click': function(ev) {
2734 var uploadForm = ev.target.nextElementSibling,
2735 fileInput = uploadForm.querySelector('input[type="file"]');
2737 ev.target.style.display = 'none';
2738 uploadForm.style.display = '';
2741 }, _('Upload file…')),
2742 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2745 'style': 'display:none',
2746 'change': function(ev) {
2747 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2748 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2750 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2751 uploadbtn.disabled = false;
2756 'click': function(ev) {
2757 ev.preventDefault();
2758 ev.target.previousElementSibling.click();
2760 }, [ _('Browse…') ]),
2761 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2763 'class': 'btn cbi-button-save',
2764 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2766 }, [ _('Upload file') ])
2772 renderListing: function(container, path, list) {
2773 var breadcrumb = E('p'),
2776 list.sort(function(a, b) {
2777 var isDirA = (a.type == 'directory'),
2778 isDirB = (b.type == 'directory');
2780 if (isDirA != isDirB)
2781 return isDirA < isDirB;
2783 return a.name > b.name;
2786 for (var i = 0; i < list.length; i++) {
2787 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2790 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2791 selected = (entrypath == this.node.lastElementChild.value),
2792 mtime = new Date(list[i].mtime * 1000);
2794 rows.appendChild(E('li', [
2795 E('div', { 'class': 'name' }, [
2796 this.iconForType(list[i].type),
2800 'style': selected ? 'font-weight:bold' : null,
2801 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2802 entrypath, list[i].type != 'directory' ? list[i] : null)
2803 }, '%h'.format(list[i].name))
2805 E('div', { 'class': 'mtime hide-xs' }, [
2806 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2807 mtime.getFullYear(),
2808 mtime.getMonth() + 1,
2815 selected ? E('button', {
2817 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2818 }, [ _('Deselect') ]) : '',
2819 this.options.enable_remove ? E('button', {
2820 'class': 'btn cbi-button-negative',
2821 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2822 }, [ _('Delete') ]) : ''
2827 if (!rows.firstElementChild)
2828 rows.appendChild(E('em', _('No entries in this directory')));
2830 var dirs = this.splitPath(path),
2833 for (var i = 0; i < dirs.length; i++) {
2834 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2835 dom.append(breadcrumb, [
2839 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2840 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2844 dom.content(container, [
2847 E('div', { 'class': 'right' }, [
2848 this.renderUpload(path, list),
2852 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2859 handleCancel: function(ev) {
2860 var button = this.node.firstElementChild,
2861 browser = button.nextElementSibling;
2863 browser.classList.remove('open');
2864 button.style.display = '';
2866 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2868 ev.preventDefault();
2872 handleReset: function(ev) {
2873 var button = this.node.firstElementChild,
2874 hidden = this.node.lastElementChild;
2877 dom.content(button, _('Select file…'));
2879 this.handleCancel(ev);
2883 handleSelect: function(path, fileStat, ev) {
2884 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2885 ul = browser.querySelector('ul');
2887 if (fileStat == null) {
2888 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2889 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2892 var button = this.node.firstElementChild,
2893 hidden = this.node.lastElementChild;
2895 path = this.canonicalizePath(path);
2897 dom.content(button, [
2898 this.iconForType(fileStat.type),
2899 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2902 browser.classList.remove('open');
2903 button.style.display = '';
2904 hidden.value = path;
2906 this.stat = Object.assign({ path: path }, fileStat);
2907 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2912 handleFileBrowser: function(ev) {
2913 var button = ev.target,
2914 browser = button.nextElementSibling,
2915 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2917 if (path.indexOf(this.options.root_directory) != 0)
2918 path = this.options.root_directory;
2920 ev.preventDefault();
2922 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2923 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2924 dom.findClassInstance(browserEl).handleCancel(ev);
2927 button.style.display = 'none';
2928 browser.classList.add('open');
2930 return this.renderListing(browser, path, list);
2931 }, this, button, browser, path));
2935 getValue: function() {
2936 return this.node.lastElementChild.value;
2940 setValue: function(value) {
2941 this.node.lastElementChild.value = value;
2951 * Provides high level UI helper functionality.
2952 * To import the class in views, use `'require ui'`, to import it in
2953 * external JavaScript, use `L.require("ui").then(...)`.
2955 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
2956 __init__: function() {
2957 modalDiv = document.body.appendChild(
2958 dom.create('div', { id: 'modal_overlay' },
2959 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
2961 tooltipDiv = document.body.appendChild(
2962 dom.create('div', { class: 'cbi-tooltip' }));
2964 /* setup old aliases */
2965 L.showModal = this.showModal;
2966 L.hideModal = this.hideModal;
2967 L.showTooltip = this.showTooltip;
2968 L.hideTooltip = this.hideTooltip;
2969 L.itemlist = this.itemlist;
2971 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
2972 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
2973 document.addEventListener('focus', this.showTooltip.bind(this), true);
2974 document.addEventListener('blur', this.hideTooltip.bind(this), true);
2976 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
2977 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
2978 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
2982 * Display a modal overlay dialog with the specified contents.
2984 * The modal overlay dialog covers the current view preventing interaction
2985 * with the underlying view contents. Only one modal dialog instance can
2986 * be opened. Invoking showModal() while a modal dialog is already open will
2987 * replace the open dialog with a new one having the specified contents.
2989 * Additional CSS class names may be passed to influence the appearence of
2990 * the dialog. Valid values for the classes depend on the underlying theme.
2992 * @see LuCI.dom.content
2994 * @param {string} [title]
2995 * The title of the dialog. If `null`, no title element will be rendered.
2997 * @param {*} contents
2998 * The contents to add to the modal dialog. This should be a DOM node or
2999 * a document fragment in most cases. The value is passed as-is to the
3000 * `dom.content()` function - refer to its documentation for applicable
3003 * @param {...string} [classes]
3004 * A number of extra CSS class names which are set on the modal dialog
3008 * Returns a DOM Node representing the modal dialog element.
3010 showModal: function(title, children /* , ... */) {
3011 var dlg = modalDiv.firstElementChild;
3013 dlg.setAttribute('class', 'modal');
3015 for (var i = 2; i < arguments.length; i++)
3016 dlg.classList.add(arguments[i]);
3018 dom.content(dlg, dom.create('h4', {}, title));
3019 dom.append(dlg, children);
3021 document.body.classList.add('modal-overlay-active');
3027 * Close the open modal overlay dialog.
3029 * This function will close an open modal dialog and restore the normal view
3030 * behaviour. It has no effect if no modal dialog is currently open.
3032 * Note that this function is stand-alone, it does not rely on `this` and
3033 * will not invoke other class functions so it suitable to be used as event
3034 * handler as-is without the need to bind it first.
3036 hideModal: function() {
3037 document.body.classList.remove('modal-overlay-active');
3041 showTooltip: function(ev) {
3042 var target = findParent(ev.target, '[data-tooltip]');
3047 if (tooltipTimeout !== null) {
3048 window.clearTimeout(tooltipTimeout);
3049 tooltipTimeout = null;
3052 var rect = target.getBoundingClientRect(),
3053 x = rect.left + window.pageXOffset,
3054 y = rect.top + rect.height + window.pageYOffset;
3056 tooltipDiv.className = 'cbi-tooltip';
3057 tooltipDiv.innerHTML = 'â–² ';
3058 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3060 if (target.hasAttribute('data-tooltip-style'))
3061 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3063 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3064 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3065 tooltipDiv.firstChild.data = 'â–¼ ' + tooltipDiv.firstChild.data.substr(2);
3068 tooltipDiv.style.top = y + 'px';
3069 tooltipDiv.style.left = x + 'px';
3070 tooltipDiv.style.opacity = 1;
3072 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3074 detail: { target: target }
3079 hideTooltip: function(ev) {
3080 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3081 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3084 if (tooltipTimeout !== null) {
3085 window.clearTimeout(tooltipTimeout);
3086 tooltipTimeout = null;
3089 tooltipDiv.style.opacity = 0;
3090 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3092 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3096 * Add a notification banner at the top of the current view.
3098 * A notification banner is an alert message usually displayed at the
3099 * top of the current view, spanning the entire availibe width.
3100 * Notification banners will stay in place until dismissed by the user.
3101 * Multiple banners may be shown at the same time.
3103 * Additional CSS class names may be passed to influence the appearence of
3104 * the banner. Valid values for the classes depend on the underlying theme.
3106 * @see LuCI.dom.content
3108 * @param {string} [title]
3109 * The title of the notification banner. If `null`, no title element
3112 * @param {*} contents
3113 * The contents to add to the notification banner. This should be a DOM
3114 * node or a document fragment in most cases. The value is passed as-is
3115 * to the `dom.content()` function - refer to its documentation for
3116 * applicable values.
3118 * @param {...string} [classes]
3119 * A number of extra CSS class names which are set on the notification
3123 * Returns a DOM Node representing the notification banner element.
3125 addNotification: function(title, children /*, ... */) {
3126 var mc = document.querySelector('#maincontent') || document.body;
3127 var msg = E('div', {
3128 'class': 'alert-message fade-in',
3129 'style': 'display:flex',
3130 'transitionend': function(ev) {
3131 var node = ev.currentTarget;
3132 if (node.parentNode && node.classList.contains('fade-out'))
3133 node.parentNode.removeChild(node);
3136 E('div', { 'style': 'flex:10' }),
3137 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3140 'style': 'margin-left:auto; margin-top:auto',
3141 'click': function(ev) {
3142 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3145 }, [ _('Dismiss') ])
3150 dom.append(msg.firstElementChild, E('h4', {}, title));
3152 dom.append(msg.firstElementChild, children);
3154 for (var i = 2; i < arguments.length; i++)
3155 msg.classList.add(arguments[i]);
3157 mc.insertBefore(msg, mc.firstElementChild);
3163 * Display or update an header area indicator.
3165 * An indicator is a small label displayed in the header area of the screen
3166 * providing few amounts of status information such as item counts or state
3167 * toggle indicators.
3169 * Multiple indicators may be shown at the same time and indicator labels
3170 * may be made clickable to display extended information or to initiate
3173 * Indicators can either use a default `active` or a less accented `inactive`
3174 * style which is useful for indicators representing state toggles.
3176 * @param {string} id
3177 * The ID of the indicator. If an indicator with the given ID already exists,
3178 * it is updated with the given label and style.
3180 * @param {string} label
3181 * The text to display in the indicator label.
3183 * @param {function} [handler]
3184 * A handler function to invoke when the indicator label is clicked/touched
3185 * by the user. If omitted, the indicator is not clickable/touchable.
3187 * Note that this parameter only applies to new indicators, when updating
3188 * existing labels it is ignored.
3190 * @param {string} [style=active]
3191 * The indicator style to use. May be either `active` or `inactive`.
3193 * @returns {boolean}
3194 * Returns `true` when the indicator has been updated or `false` when no
3195 * changes were made.
3197 showIndicator: function(id, label, handler, style) {
3198 if (indicatorDiv == null) {
3199 indicatorDiv = document.body.querySelector('#indicators');
3201 if (indicatorDiv == null)
3205 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3206 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) ||
3207 indicatorDiv.appendChild(E('span', {
3208 'data-indicator': id,
3209 'data-clickable': handlerFn ? true : null,
3213 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3216 indicatorElem.firstChild.data = label;
3217 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3222 * Remove an header area indicator.
3224 * This function removes the given indicator label from the header indicator
3225 * area. When the given indicator is not found, this function does nothing.
3227 * @param {string} id
3228 * The ID of the indicator to remove.
3230 * @returns {boolean}
3231 * Returns `true` when the indicator has been removed or `false` when the
3232 * requested indicator was not found.
3234 hideIndicator: function(id) {
3235 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3237 if (indicatorElem == null)
3240 indicatorDiv.removeChild(indicatorElem);
3245 * Formats a series of label/value pairs into list-like markup.
3247 * This function transforms a flat array of alternating label and value
3248 * elements into a list-like markup, using the values in `separators` as
3249 * separators and appends the resulting nodes to the given parent DOM node.
3251 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3252 * `<strong>` element and the value corresponding to the label are
3253 * subsequently wrapped into a `<span class="nowrap">` element.
3255 * The resulting `<span>` element tuples are joined by the given separators
3256 * to form the final markup which is appened to the given parent DOM node.
3258 * @param {Node} node
3259 * The parent DOM node to append the markup to. Any previous child elements
3262 * @param {Array<*>} items
3263 * An alternating array of labels and values. The label values will be
3264 * converted to plain strings, the values are used as-is and may be of
3265 * any type accepted by `LuCI.dom.content()`.
3267 * @param {*|Array<*>} [separators=[E('br')]]
3268 * A single value or an array of separator values to separate each
3269 * label/value pair with. The function will cycle through the separators
3270 * when joining the pairs. If omitted, the default separator is a sole HTML
3271 * `<br>` element. Separator values are used as-is and may be of any type
3272 * accepted by `LuCI.dom.content()`.
3275 * Returns the parent DOM node the formatted markup has been added to.
3277 itemlist: function(node, items, separators) {
3280 if (!Array.isArray(separators))
3281 separators = [ separators || E('br') ];
3283 for (var i = 0; i < items.length; i += 2) {
3284 if (items[i+1] !== null && items[i+1] !== undefined) {
3285 var sep = separators[(i/2) % separators.length],
3288 children.push(E('span', { class: 'nowrap' }, [
3289 items[i] ? E('strong', items[i] + ': ') : '',
3293 if ((i+2) < items.length)
3294 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3298 dom.content(node, children);
3309 * The `tabs` class handles tab menu groups used throughout the view area.
3310 * It takes care of setting up tab groups, tracking their state and handling
3313 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3314 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3315 * external JavaScript, use `L.require("ui").then(...)` and access the
3316 * `tabs` property of the class instance value.
3318 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3321 var groups = [], prevGroup = null, currGroup = null;
3323 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3324 var parent = tab.parentNode;
3326 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3329 if (!parent.hasAttribute('data-tab-group'))
3330 parent.setAttribute('data-tab-group', groups.length);
3332 currGroup = +parent.getAttribute('data-tab-group');
3334 if (currGroup !== prevGroup) {
3335 prevGroup = currGroup;
3337 if (!groups[currGroup])
3338 groups[currGroup] = [];
3341 groups[currGroup].push(tab);
3344 for (var i = 0; i < groups.length; i++)
3345 this.initTabGroup(groups[i]);
3347 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3353 * Initializes a new tab group from the given tab pane collection.
3355 * This function cycles through the given tab pane DOM nodes, extracts
3356 * their tab IDs, titles and active states, renders a corresponding
3357 * tab menu and prepends it to the tab panes common parent DOM node.
3359 * The tab menu labels will be set to the value of the `data-tab-title`
3360 * attribute of each corresponding pane. The last pane with the
3361 * `data-tab-active` attribute set to `true` will be selected by default.
3363 * If no pane is marked as active, the first one will be preselected.
3366 * @memberof LuCI.ui.tabs
3367 * @param {Array<Node>|NodeList} panes
3368 * A collection of tab panes to build a tab group menu for. May be a
3369 * plain array of DOM nodes or a NodeList collection, such as the result
3370 * of a `querySelectorAll()` call or the `.childNodes` property of a
3373 initTabGroup: function(panes) {
3374 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3377 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3378 group = panes[0].parentNode,
3379 groupId = +group.getAttribute('data-tab-group'),
3382 if (group.getAttribute('data-initialized') === 'true')
3385 for (var i = 0, pane; pane = panes[i]; i++) {
3386 var name = pane.getAttribute('data-tab'),
3387 title = pane.getAttribute('data-tab-title'),
3388 active = pane.getAttribute('data-tab-active') === 'true';
3390 menu.appendChild(E('li', {
3391 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3392 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3396 'click': this.switchTab.bind(this)
3403 group.parentNode.insertBefore(menu, group);
3404 group.setAttribute('data-initialized', true);
3406 if (selected === null) {
3407 selected = this.getActiveTabId(panes[0]);
3409 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3410 for (var i = 0; i < panes.length; i++) {
3411 if (!this.isEmptyPane(panes[i])) {
3418 menu.childNodes[selected].classList.add('cbi-tab');
3419 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3420 panes[selected].setAttribute('data-tab-active', 'true');
3422 this.setActiveTabId(panes[selected], selected);
3425 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3426 detail: { tab: panes[selected].getAttribute('data-tab') }
3429 this.updateTabs(group);
3433 * Checks whether the given tab pane node is empty.
3436 * @memberof LuCI.ui.tabs
3437 * @param {Node} pane
3438 * The tab pane to check.
3440 * @returns {boolean}
3441 * Returns `true` if the pane is empty, else `false`.
3443 isEmptyPane: function(pane) {
3444 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3448 getPathForPane: function(pane) {
3449 var path = [], node = null;
3451 for (node = pane ? pane.parentNode : null;
3452 node != null && node.hasAttribute != null;
3453 node = node.parentNode)
3455 if (node.hasAttribute('data-tab'))
3456 path.unshift(node.getAttribute('data-tab'));
3457 else if (node.hasAttribute('data-section-id'))
3458 path.unshift(node.getAttribute('data-section-id'));
3461 return path.join('/');
3465 getActiveTabState: function() {
3466 var page = document.body.getAttribute('data-page');
3469 var val = JSON.parse(window.sessionStorage.getItem('tab'));
3470 if (val.page === page && L.isObject(val.paths))
3475 window.sessionStorage.removeItem('tab');
3476 return { page: page, paths: {} };
3480 getActiveTabId: function(pane) {
3481 var path = this.getPathForPane(pane);
3482 return +this.getActiveTabState().paths[path] || 0;
3486 setActiveTabId: function(pane, tabIndex) {
3487 var path = this.getPathForPane(pane);
3490 var state = this.getActiveTabState();
3491 state.paths[path] = tabIndex;
3493 window.sessionStorage.setItem('tab', JSON.stringify(state));
3495 catch (e) { return false; }
3501 updateTabs: function(ev, root) {
3502 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3503 var menu = pane.parentNode.previousElementSibling,
3504 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3505 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3510 if (this.isEmptyPane(pane)) {
3511 tab.style.display = 'none';
3512 tab.classList.remove('flash');
3514 else if (tab.style.display === 'none') {
3515 tab.style.display = '';
3516 requestAnimationFrame(function() { tab.classList.add('flash') });
3520 tab.setAttribute('data-errors', n_errors);
3521 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3522 tab.setAttribute('data-tooltip-style', 'error');
3525 tab.removeAttribute('data-errors');
3526 tab.removeAttribute('data-tooltip');
3532 switchTab: function(ev) {
3533 var tab = ev.target.parentNode,
3534 name = tab.getAttribute('data-tab'),
3535 menu = tab.parentNode,
3536 group = menu.nextElementSibling,
3537 groupId = +group.getAttribute('data-tab-group'),
3540 ev.preventDefault();
3542 if (!tab.classList.contains('cbi-tab-disabled'))
3545 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3546 tab.classList.remove('cbi-tab');
3547 tab.classList.remove('cbi-tab-disabled');
3549 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3552 group.childNodes.forEach(function(pane) {
3553 if (dom.matches(pane, '[data-tab]')) {
3554 if (pane.getAttribute('data-tab') === name) {
3555 pane.setAttribute('data-tab-active', 'true');
3556 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3557 UI.prototype.tabs.setActiveTabId(pane, index);
3560 pane.setAttribute('data-tab-active', 'false');
3570 * @typedef {Object} FileUploadReply
3573 * @property {string} name - Name of the uploaded file without directory components
3574 * @property {number} size - Size of the uploaded file in bytes
3575 * @property {string} checksum - The MD5 checksum of the received file data
3576 * @property {string} sha256sum - The SHA256 checksum of the received file data
3580 * Display a modal file upload prompt.
3582 * This function opens a modal dialog prompting the user to select and
3583 * upload a file to a predefined remote destination path.
3585 * @param {string} path
3586 * The remote file path to upload the local file to.
3588 * @param {Node} [progessStatusNode]
3589 * An optional DOM text node whose content text is set to the progress
3590 * percentage value during file upload.
3592 * @returns {Promise<LuCI.ui.FileUploadReply>}
3593 * Returns a promise resolving to a file upload status object on success
3594 * or rejecting with an error in case the upload failed or has been
3595 * cancelled by the user.
3597 uploadFile: function(path, progressStatusNode) {
3598 return new Promise(function(resolveFn, rejectFn) {
3599 UI.prototype.showModal(_('Uploading file…'), [
3600 E('p', _('Please select the file to upload.')),
3601 E('div', { 'style': 'display:flex' }, [
3602 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3605 style: 'display:none',
3606 change: function(ev) {
3607 var modal = dom.parent(ev.target, '.modal'),
3608 body = modal.querySelector('p'),
3609 upload = modal.querySelector('.cbi-button-action.important'),
3610 file = ev.currentTarget.files[0];
3617 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3618 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3622 upload.disabled = false;
3628 'click': function(ev) {
3629 ev.target.previousElementSibling.click();
3631 }, [ _('Browse…') ])
3633 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3636 'click': function() {
3637 UI.prototype.hideModal();
3638 rejectFn(new Error('Upload has been cancelled'));
3640 }, [ _('Cancel') ]),
3643 'class': 'btn cbi-button-action important',
3645 'click': function(ev) {
3646 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3648 if (!input.files[0])
3651 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3653 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3655 var data = new FormData();
3657 data.append('sessionid', rpc.getSessionID());
3658 data.append('filename', path);
3659 data.append('filedata', input.files[0]);
3661 var filename = input.files[0].name;
3663 request.post(L.env.cgi_base + '/cgi-upload', data, {
3665 progress: function(pev) {
3666 var percent = (pev.loaded / pev.total) * 100;
3668 if (progressStatusNode)
3669 progressStatusNode.data = '%.2f%%'.format(percent);
3671 progress.setAttribute('title', '%.2f%%'.format(percent));
3672 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3674 }).then(function(res) {
3675 var reply = res.json();
3677 UI.prototype.hideModal();
3679 if (L.isObject(reply) && reply.failure) {
3680 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3681 rejectFn(new Error(reply.failure));
3684 reply.name = filename;
3688 UI.prototype.hideModal();
3700 * Perform a device connectivity test.
3702 * Attempt to fetch a well known ressource from the remote device via HTTP
3703 * in order to test connectivity. This function is mainly useful to wait
3704 * for the router to come back online after a reboot or reconfiguration.
3706 * @param {string} [proto=http]
3707 * The protocol to use for fetching the resource. May be either `http`
3708 * (the default) or `https`.
3710 * @param {string} [host=window.location.host]
3711 * Override the host address to probe. By default the current host as seen
3712 * in the address bar is probed.
3714 * @returns {Promise<Event>}
3715 * Returns a promise resolving to a `load` event in case the device is
3716 * reachable or rejecting with an `error` event in case it is not reachable
3717 * or rejecting with `null` when the connectivity check timed out.
3719 pingDevice: function(proto, ipaddr) {
3720 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3722 return new Promise(function(resolveFn, rejectFn) {
3723 var img = new Image();
3725 img.onload = resolveFn;
3726 img.onerror = rejectFn;
3728 window.setTimeout(rejectFn, 1000);
3735 * Wait for device to come back online and reconnect to it.
3737 * Poll each given hostname or IP address and navigate to it as soon as
3738 * one of the addresses becomes reachable.
3740 * @param {...string} [hosts=[window.location.host]]
3741 * The list of IP addresses and host names to check for reachability.
3742 * If omitted, the current value of `window.location.host` is used by
3745 awaitReconnect: function(/* ... */) {
3746 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3748 window.setTimeout(L.bind(function() {
3749 poll.add(L.bind(function() {
3750 var tasks = [], reachable = false;
3752 for (var i = 0; i < 2; i++)
3753 for (var j = 0; j < ipaddrs.length; j++)
3754 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3755 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3757 return Promise.all(tasks).then(function() {
3760 window.location = reachable;
3773 * The `changes` class encapsulates logic for visualizing, applying,
3774 * confirming and reverting staged UCI changesets.
3776 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3777 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3778 * external JavaScript, use `L.require("ui").then(...)` and access the
3779 * `changes` property of the class instance value.
3781 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3783 if (!L.env.sessionid)
3786 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3790 * Set the change count indicator.
3792 * This function updates or hides the UCI change count indicator,
3793 * depending on the passed change count. When the count is greater
3794 * than 0, the change indicator is displayed or updated, otherwise it
3798 * @memberof LuCI.ui.changes
3799 * @param {number} numChanges
3800 * The number of changes to indicate.
3802 setIndicator: function(n) {
3803 var i = document.querySelector('.uci_change_indicator');
3805 var poll = document.getElementById('xhr_poll_status');
3806 i = poll.parentNode.insertBefore(E('a', {
3808 'class': 'uci_change_indicator label notice',
3809 'click': L.bind(this.displayChanges, this)
3814 dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
3815 i.classList.add('flash');
3816 i.style.display = '';
3817 document.dispatchEvent(new CustomEvent('uci-new-changes'));
3820 i.classList.remove('flash');
3821 i.style.display = 'none';
3822 document.dispatchEvent(new CustomEvent('uci-clear-changes'));
3827 * Update the change count indicator.
3829 * This function updates the UCI change count indicator from the given
3830 * UCI changeset structure.
3833 * @memberof LuCI.ui.changes
3834 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3835 * The UCI changeset to count.
3837 renderChangeIndicator: function(changes) {
3840 for (var config in changes)
3841 if (changes.hasOwnProperty(config))
3842 n_changes += changes[config].length;
3844 this.changes = changes;
3845 this.setIndicator(n_changes);
3850 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3851 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3852 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3853 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
3854 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3855 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3856 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3857 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3858 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
3859 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3863 * Display the current changelog.
3865 * Open a modal dialog visualizing the currently staged UCI changes
3866 * and offer options to revert or apply the shown changes.
3869 * @memberof LuCI.ui.changes
3871 displayChanges: function() {
3872 var list = E('div', { 'class': 'uci-change-list' }),
3873 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3874 E('div', { 'class': 'cbi-section' }, [
3875 E('strong', _('Legend:')),
3876 E('div', { 'class': 'uci-change-legend' }, [
3877 E('div', { 'class': 'uci-change-legend-label' }, [
3878 E('ins', ' '), ' ', _('Section added') ]),
3879 E('div', { 'class': 'uci-change-legend-label' }, [
3880 E('del', ' '), ' ', _('Section removed') ]),
3881 E('div', { 'class': 'uci-change-legend-label' }, [
3882 E('var', {}, E('ins', ' ')), ' ', _('Option changed') ]),
3883 E('div', { 'class': 'uci-change-legend-label' }, [
3884 E('var', {}, E('del', ' ')), ' ', _('Option removed') ])]),
3886 E('div', { 'class': 'right' }, [
3889 'click': UI.prototype.hideModal
3890 }, [ _('Dismiss') ]), ' ',
3892 'class': 'cbi-button cbi-button-positive important',
3893 'click': L.bind(this.apply, this, true)
3894 }, [ _('Save & Apply') ]), ' ',
3896 'class': 'cbi-button cbi-button-reset',
3897 'click': L.bind(this.revert, this)
3898 }, [ _('Revert') ])])])
3901 for (var config in this.changes) {
3902 if (!this.changes.hasOwnProperty(config))
3905 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
3907 for (var i = 0, added = null; i < this.changes[config].length; i++) {
3908 var chg = this.changes[config][i],
3909 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
3911 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
3917 if (added != null && chg[1] == added[0])
3918 return '@' + added[1] + '[-1]';
3923 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
3930 if (chg[0] == 'add')
3931 added = [ chg[1], chg[2] ];
3935 list.appendChild(E('br'));
3936 dlg.classList.add('uci-dialog');
3940 displayStatus: function(type, content) {
3942 var message = UI.prototype.showModal('', '');
3944 message.classList.add('alert-message');
3945 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
3948 dom.content(message, content);
3950 if (!this.was_polling) {
3951 this.was_polling = request.poll.active();
3952 request.poll.stop();
3956 UI.prototype.hideModal();
3958 if (this.was_polling)
3959 request.poll.start();
3964 rollback: function(checked) {
3966 this.displayStatus('warning spinning',
3967 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
3968 .format(L.env.apply_rollback)));
3970 var call = function(r, data, duration) {
3971 if (r.status === 204) {
3972 UI.prototype.changes.displayStatus('warning', [
3973 E('h4', _('Configuration changes have been rolled back!')),
3974 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)),
3975 E('div', { 'class': 'right' }, [
3978 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
3979 }, [ _('Dismiss') ]), ' ',
3981 'class': 'btn cbi-button-action important',
3982 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
3983 }, [ _('Revert changes') ]), ' ',
3985 'class': 'btn cbi-button-negative important',
3986 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
3987 }, [ _('Apply unchecked') ])
3994 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
3995 window.setTimeout(function() {
3996 request.request(L.url('admin/uci/confirm'), {
3998 timeout: L.env.apply_timeout * 1000,
3999 query: { sid: L.env.sessionid, token: L.env.token }
4004 call({ status: 0 });
4007 this.displayStatus('warning', [
4008 E('h4', _('Device unreachable!')),
4009 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.'))
4015 confirm: function(checked, deadline, override_token) {
4017 var ts = Date.now();
4019 this.displayStatus('notice');
4022 this.confirm_auth = { token: override_token };
4024 var call = function(r, data, duration) {
4025 if (Date.now() >= deadline) {
4026 window.clearTimeout(tt);
4027 UI.prototype.changes.rollback(checked);
4030 else if (r && (r.status === 200 || r.status === 204)) {
4031 document.dispatchEvent(new CustomEvent('uci-applied'));
4033 UI.prototype.changes.setIndicator(0);
4034 UI.prototype.changes.displayStatus('notice',
4035 E('p', _('Configuration changes applied.')));
4037 window.clearTimeout(tt);
4038 window.setTimeout(function() {
4039 //UI.prototype.changes.displayStatus(false);
4040 window.location = window.location.href.split('#')[0];
4041 }, L.env.apply_display * 1000);
4046 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4047 window.setTimeout(function() {
4048 request.request(L.url('admin/uci/confirm'), {
4050 timeout: L.env.apply_timeout * 1000,
4051 query: UI.prototype.changes.confirm_auth
4052 }).then(call, call);
4056 var tick = function() {
4057 var now = Date.now();
4059 UI.prototype.changes.displayStatus('notice spinning',
4060 E('p', _('Applying configuration changes… %ds')
4061 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4063 if (now >= deadline)
4066 tt = window.setTimeout(tick, 1000 - (now - ts));
4072 /* wait a few seconds for the settings to become effective */
4073 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4077 * Apply the staged configuration changes.
4079 * Start applying staged configuration changes and open a modal dialog
4080 * with a progress indication to prevent interaction with the view
4081 * during the apply process. The modal dialog will be automatically
4082 * closed and the current view reloaded once the apply process is
4086 * @memberof LuCI.ui.changes
4087 * @param {boolean} [checked=false]
4088 * Whether to perform a checked (`true`) configuration apply or an
4089 * unchecked (`false`) one.
4091 * In case of a checked apply, the configuration changes must be
4092 * confirmed within a specific time interval, otherwise the device
4093 * will begin to roll back the changes in order to restore the previous
4096 apply: function(checked) {
4097 this.displayStatus('notice spinning',
4098 E('p', _('Starting configuration apply…')));
4100 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4102 query: { sid: L.env.sessionid, token: L.env.token }
4103 }).then(function(r) {
4104 if (r.status === (checked ? 200 : 204)) {
4105 var tok = null; try { tok = r.json(); } catch(e) {}
4106 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4107 UI.prototype.changes.confirm_auth = tok;
4109 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4111 else if (checked && r.status === 204) {
4112 UI.prototype.changes.displayStatus('notice',
4113 E('p', _('There are no changes to apply')));
4115 window.setTimeout(function() {
4116 UI.prototype.changes.displayStatus(false);
4117 }, L.env.apply_display * 1000);
4120 UI.prototype.changes.displayStatus('warning',
4121 E('p', _('Apply request failed with status <code>%h</code>')
4122 .format(r.responseText || r.statusText || r.status)));
4124 window.setTimeout(function() {
4125 UI.prototype.changes.displayStatus(false);
4126 }, L.env.apply_display * 1000);
4132 * Revert the staged configuration changes.
4134 * Start reverting staged configuration changes and open a modal dialog
4135 * with a progress indication to prevent interaction with the view
4136 * during the revert process. The modal dialog will be automatically
4137 * closed and the current view reloaded once the revert process is
4141 * @memberof LuCI.ui.changes
4143 revert: function() {
4144 this.displayStatus('notice spinning',
4145 E('p', _('Reverting configuration…')));
4147 request.request(L.url('admin/uci/revert'), {
4149 query: { sid: L.env.sessionid, token: L.env.token }
4150 }).then(function(r) {
4151 if (r.status === 200) {
4152 document.dispatchEvent(new CustomEvent('uci-reverted'));
4154 UI.prototype.changes.setIndicator(0);
4155 UI.prototype.changes.displayStatus('notice',
4156 E('p', _('Changes have been reverted.')));
4158 window.setTimeout(function() {
4159 //UI.prototype.changes.displayStatus(false);
4160 window.location = window.location.href.split('#')[0];
4161 }, L.env.apply_display * 1000);
4164 UI.prototype.changes.displayStatus('warning',
4165 E('p', _('Revert request failed with status <code>%h</code>')
4166 .format(r.statusText || r.status)));
4168 window.setTimeout(function() {
4169 UI.prototype.changes.displayStatus(false);
4170 }, L.env.apply_display * 1000);
4177 * Add validation constraints to an input element.
4179 * Compile the given type expression and optional validator function into
4180 * a validation function and bind it to the specified input element events.
4182 * @param {Node} field
4183 * The DOM input element node to bind the validation constraints to.
4185 * @param {string} type
4186 * The datatype specification to describe validation constraints.
4187 * Refer to the `LuCI.validation` class documentation for details.
4189 * @param {boolean} [optional=false]
4190 * Specifies whether empty values are allowed (`true`) or not (`false`).
4191 * If an input element is not marked optional it must not be empty,
4192 * otherwise it will be marked as invalid.
4194 * @param {function} [vfunc]
4195 * Specifies a custom validation function which is invoked after the
4196 * other validation constraints are applied. The validation must return
4197 * `true` to accept the passed value. Any other return type is converted
4198 * to a string and treated as validation error message.
4200 * @param {...string} [events=blur, keyup]
4201 * The list of events to bind. Each received event will trigger a field
4202 * validation. If omitted, the `keyup` and `blur` events are bound by
4205 * @returns {function}
4206 * Returns the compiled validator function which can be used to manually
4207 * trigger field validation or to bind it to further events.
4209 * @see LuCI.validation
4211 addValidator: function(field, type, optional, vfunc /*, ... */) {
4215 var events = this.varargs(arguments, 3);
4216 if (events.length == 0)
4217 events.push('blur', 'keyup');
4220 var cbiValidator = validation.create(field, type, optional, vfunc),
4221 validatorFn = cbiValidator.validate.bind(cbiValidator);
4223 for (var i = 0; i < events.length; i++)
4224 field.addEventListener(events[i], validatorFn);
4234 * Create a pre-bound event handler function.
4236 * Generate and bind a function suitable for use in event handlers. The
4237 * generated function automatically disables the event source element
4238 * and adds an active indication to it by adding appropriate CSS classes.
4240 * It will also await any promises returned by the wrapped function and
4241 * re-enable the source element after the promises ran to completion.
4244 * The `this` context to use for the wrapped function.
4246 * @param {function|string} fn
4247 * Specifies the function to wrap. In case of a function value, the
4248 * function is used as-is. If a string is specified instead, it is looked
4249 * up in `ctx` to obtain the function to wrap. In both cases the bound
4250 * function will be invoked with `ctx` as `this` context
4252 * @param {...*} extra_args
4253 * Any further parameter as passed as-is to the bound event handler
4254 * function in the same order as passed to `createHandlerFn()`.
4256 * @returns {function|null}
4257 * Returns the pre-bound handler function which is suitable to be passed
4258 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4259 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4260 * valid function value.
4262 createHandlerFn: function(ctx, fn /*, ... */) {
4263 if (typeof(fn) == 'string')
4266 if (typeof(fn) != 'function')
4269 var arg_offset = arguments.length - 2;
4271 return Function.prototype.bind.apply(function() {
4272 var t = arguments[arg_offset].currentTarget;
4274 t.classList.add('spinning');
4280 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4281 t.classList.remove('spinning');
4284 }, this.varargs(arguments, 2, ctx));
4288 * Load specified view class path and set it up.
4290 * Transforms the given view path into a class name, requires it
4291 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4292 * resulting class instance is a descendant of
4293 * [LuCI.view]{@link LuCI.view}.
4295 * By instantiating the view class, its corresponding contents are
4296 * rendered and included into the view area. Any runtime errors are
4297 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4299 * @param {string} path
4300 * The view path to render.
4302 * @returns {Promise<LuCI.view>}
4303 * Returns a promise resolving to the loaded view instance.
4305 instantiateView: function(path) {
4306 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4308 return L.require(className).then(function(view) {
4309 if (!(view instanceof View))
4310 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4313 }).catch(function(err) {
4314 dom.content(document.querySelector('#view'), null);
4319 AbstractElement: UIElement,
4322 Textfield: UITextfield,
4323 Textarea: UITextarea,
4324 Checkbox: UICheckbox,
4326 Dropdown: UIDropdown,
4327 DynamicList: UIDynamicList,
4328 Combobox: UICombobox,
4329 ComboButton: UIComboButton,
4330 Hiddenfield: UIHiddenfield,
4331 FileUpload: UIFileUpload