luci-mod-network: add disabled option for interface
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2 'require validation';
3 'require baseclass';
4 'require request';
5 'require session';
6 'require poll';
7 'require dom';
8 'require rpc';
9 'require uci';
10 'require fs';
11
12 var modalDiv = null,
13 tooltipDiv = null,
14 indicatorDiv = null,
15 tooltipTimeout = null;
16
17 /**
18 * @class AbstractElement
19 * @memberof LuCI.ui
20 * @hideconstructor
21 * @classdesc
22 *
23 * The `AbstractElement` class serves as abstract base for the different widgets
24 * implemented by `LuCI.ui`. It provides the common logic for getting and
25 * setting values, for checking the validity state and for wiring up required
26 * events.
27 *
28 * UI widget instances are usually not supposed to be created by view code
29 * directly, instead they're implicitly created by `LuCI.form` when
30 * instantiating CBI forms.
31 *
32 * This class is automatically instantiated as part of `LuCI.ui`. To use it
33 * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
34 * it in external JavaScript, use `L.require("ui").then(...)` and access the
35 * `AbstractElement` property of the class instance value.
36 */
37 var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
38 /**
39 * @typedef {Object} InitOptions
40 * @memberof LuCI.ui.AbstractElement
41 *
42 * @property {string} [id]
43 * Specifies the widget ID to use. It will be used as HTML `id` attribute
44 * on the toplevel widget DOM node.
45 *
46 * @property {string} [name]
47 * Specifies the widget name which is set as HTML `name` attribute on the
48 * corresponding `<input>` element.
49 *
50 * @property {boolean} [optional=true]
51 * Specifies whether the input field allows empty values.
52 *
53 * @property {string} [datatype=string]
54 * An expression describing the input data validation constraints.
55 * It defaults to `string` which will allow any value.
56 * See {@link LuCI.validation} for details on the expression format.
57 *
58 * @property {function} [validator]
59 * Specifies a custom validator function which is invoked after the
60 * standard validation constraints are checked. The function should return
61 * `true` to accept the given input value. Any other return value type is
62 * converted to a string and treated as validation error message.
63 *
64 * @property {boolean} [disabled=false]
65 * Specifies whether the widget should be rendered in disabled state
66 * (`true`) or not (`false`). Disabled widgets cannot be interacted with
67 * and are displayed in a slightly faded style.
68 */
69
70 /**
71 * Read the current value of the input widget.
72 *
73 * @instance
74 * @memberof LuCI.ui.AbstractElement
75 * @returns {string|string[]|null}
76 * The current value of the input element. For simple inputs like text
77 * fields or selects, the return value type will be a - possibly empty -
78 * string. Complex widgets such as `DynamicList` instances may result in
79 * an array of strings or `null` for unset values.
80 */
81 getValue: function() {
82 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
83 return this.node.value;
84
85 return null;
86 },
87
88 /**
89 * Set the current value of the input widget.
90 *
91 * @instance
92 * @memberof LuCI.ui.AbstractElement
93 * @param {string|string[]|null} value
94 * The value to set the input element to. For simple inputs like text
95 * fields or selects, the value should be a - possibly empty - string.
96 * Complex widgets such as `DynamicList` instances may accept string array
97 * or `null` values.
98 */
99 setValue: function(value) {
100 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
101 this.node.value = value;
102 },
103
104 /**
105 * Set the current placeholder value of the input widget.
106 *
107 * @instance
108 * @memberof LuCI.ui.AbstractElement
109 * @param {string|string[]|null} value
110 * The placeholder to set for the input element. Only applicable to text
111 * inputs, not to radio buttons, selects or similar.
112 */
113 setPlaceholder: function(value) {
114 var node = this.node ? this.node.querySelector('input,textarea') : null;
115 if (node) {
116 switch (node.getAttribute('type') || 'text') {
117 case 'password':
118 case 'search':
119 case 'tel':
120 case 'text':
121 case 'url':
122 if (value != null && value != '')
123 node.setAttribute('placeholder', value);
124 else
125 node.removeAttribute('placeholder');
126 }
127 }
128 },
129
130 /**
131 * Check whether the input value was altered by the user.
132 *
133 * @instance
134 * @memberof LuCI.ui.AbstractElement
135 * @returns {boolean}
136 * Returns `true` if the input value has been altered by the user or
137 * `false` if it is unchanged. Note that if the user modifies the initial
138 * value and changes it back to the original state, it is still reported
139 * as changed.
140 */
141 isChanged: function() {
142 return (this.node ? this.node.getAttribute('data-changed') : null) == 'true';
143 },
144
145 /**
146 * Check whether the current input value is valid.
147 *
148 * @instance
149 * @memberof LuCI.ui.AbstractElement
150 * @returns {boolean}
151 * Returns `true` if the current input value is valid or `false` if it does
152 * not meet the validation constraints.
153 */
154 isValid: function() {
155 return (this.validState !== false);
156 },
157
158 /**
159 * Returns the current validation error
160 *
161 * @instance
162 * @memberof LuCI.ui.AbstractElement
163 * @returns {string}
164 * The validation error at this time
165 */
166 getValidationError: function() {
167 return this.validationError || '';
168 },
169
170 /**
171 * Force validation of the current input value.
172 *
173 * Usually input validation is automatically triggered by various DOM events
174 * bound to the input widget. In some cases it is required though to manually
175 * trigger validation runs, e.g. when programmatically altering values.
176 *
177 * @instance
178 * @memberof LuCI.ui.AbstractElement
179 */
180 triggerValidation: function() {
181 if (typeof(this.vfunc) != 'function')
182 return false;
183
184 var wasValid = this.isValid();
185
186 this.vfunc();
187
188 return (wasValid != this.isValid());
189 },
190
191 /**
192 * Dispatch a custom (synthetic) event in response to received events.
193 *
194 * Sets up event handlers on the given target DOM node for the given event
195 * names that dispatch a custom event of the given type to the widget root
196 * DOM node.
197 *
198 * The primary purpose of this function is to set up a series of custom
199 * uniform standard events such as `widget-update`, `validation-success`,
200 * `validation-failure` etc. which are triggered by various different
201 * widget specific native DOM events.
202 *
203 * @instance
204 * @memberof LuCI.ui.AbstractElement
205 * @param {Node} targetNode
206 * Specifies the DOM node on which the native event listeners should be
207 * registered.
208 *
209 * @param {string} synevent
210 * The name of the custom event to dispatch to the widget root DOM node.
211 *
212 * @param {string[]} events
213 * The native DOM events for which event handlers should be registered.
214 */
215 registerEvents: function(targetNode, synevent, events) {
216 var dispatchFn = L.bind(function(ev) {
217 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
218 }, this);
219
220 for (var i = 0; i < events.length; i++)
221 targetNode.addEventListener(events[i], dispatchFn);
222 },
223
224 /**
225 * Setup listeners for native DOM events that may update the widget value.
226 *
227 * Sets up event handlers on the given target DOM node for the given event
228 * names which may cause the input value to update, such as `keyup` or
229 * `onclick` events. In contrast to change events, such update events will
230 * trigger input value validation.
231 *
232 * @instance
233 * @memberof LuCI.ui.AbstractElement
234 * @param {Node} targetNode
235 * Specifies the DOM node on which the event listeners should be registered.
236 *
237 * @param {...string} events
238 * The DOM events for which event handlers should be registered.
239 */
240 setUpdateEvents: function(targetNode /*, ... */) {
241 var datatype = this.options.datatype,
242 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
243 validate = this.options.validate,
244 events = this.varargs(arguments, 1);
245
246 this.registerEvents(targetNode, 'widget-update', events);
247
248 if (!datatype && !validate)
249 return;
250
251 this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
252 targetNode, datatype || 'string',
253 optional, validate
254 ].concat(events));
255
256 this.node.addEventListener('validation-success', L.bind(function(ev) {
257 this.validState = true;
258 this.validationError = '';
259 }, this));
260
261 this.node.addEventListener('validation-failure', L.bind(function(ev) {
262 this.validState = false;
263 this.validationError = ev.detail.message;
264 }, this));
265 },
266
267 /**
268 * Setup listeners for native DOM events that may change the widget value.
269 *
270 * Sets up event handlers on the given target DOM node for the given event
271 * names which may cause the input value to change completely, such as
272 * `change` events in a select menu. In contrast to update events, such
273 * change events will not trigger input value validation but they may cause
274 * field dependencies to get re-evaluated and will mark the input widget
275 * as dirty.
276 *
277 * @instance
278 * @memberof LuCI.ui.AbstractElement
279 * @param {Node} targetNode
280 * Specifies the DOM node on which the event listeners should be registered.
281 *
282 * @param {...string} events
283 * The DOM events for which event handlers should be registered.
284 */
285 setChangeEvents: function(targetNode /*, ... */) {
286 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
287
288 for (var i = 1; i < arguments.length; i++)
289 targetNode.addEventListener(arguments[i], tag_changed);
290
291 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
292 },
293
294 /**
295 * Render the widget, setup event listeners and return resulting markup.
296 *
297 * @instance
298 * @memberof LuCI.ui.AbstractElement
299 *
300 * @returns {Node}
301 * Returns a DOM Node or DocumentFragment containing the rendered
302 * widget markup.
303 */
304 render: function() {}
305 });
306
307 /**
308 * Instantiate a text input widget.
309 *
310 * @constructor Textfield
311 * @memberof LuCI.ui
312 * @augments LuCI.ui.AbstractElement
313 *
314 * @classdesc
315 *
316 * The `Textfield` class implements a standard single line text input field.
317 *
318 * UI widget instances are usually not supposed to be created by view code
319 * directly, instead they're implicitly created by `LuCI.form` when
320 * instantiating CBI forms.
321 *
322 * This class is automatically instantiated as part of `LuCI.ui`. To use it
323 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
324 * external JavaScript, use `L.require("ui").then(...)` and access the
325 * `Textfield` property of the class instance value.
326 *
327 * @param {string} [value=null]
328 * The initial input value.
329 *
330 * @param {LuCI.ui.Textfield.InitOptions} [options]
331 * Object describing the widget specific options to initialize the input.
332 */
333 var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
334 /**
335 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
336 * the following properties are recognized:
337 *
338 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
339 * @memberof LuCI.ui.Textfield
340 *
341 * @property {boolean} [password=false]
342 * Specifies whether the input should be rendered as concealed password field.
343 *
344 * @property {boolean} [readonly=false]
345 * Specifies whether the input widget should be rendered readonly.
346 *
347 * @property {number} [maxlength]
348 * Specifies the HTML `maxlength` attribute to set on the corresponding
349 * `<input>` element. Note that this a legacy property that exists for
350 * compatibility reasons. It is usually better to `maxlength(N)` validation
351 * expression.
352 *
353 * @property {string} [placeholder]
354 * Specifies the HTML `placeholder` attribute which is displayed when the
355 * corresponding `<input>` element is empty.
356 */
357 __init__: function(value, options) {
358 this.value = value;
359 this.options = Object.assign({
360 optional: true,
361 password: false
362 }, options);
363 },
364
365 /** @override */
366 render: function() {
367 var frameEl = E('div', { 'id': this.options.id });
368 var inputEl = E('input', {
369 'id': this.options.id ? 'widget.' + this.options.id : null,
370 'name': this.options.name,
371 'type': 'text',
372 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
373 'readonly': this.options.readonly ? '' : null,
374 'disabled': this.options.disabled ? '' : null,
375 'maxlength': this.options.maxlength,
376 'placeholder': this.options.placeholder,
377 'autocomplete': this.options.password ? 'new-password' : null,
378 'value': this.value,
379 });
380
381 if (this.options.password) {
382 frameEl.appendChild(E('div', { 'class': 'control-group' }, [
383 inputEl,
384 E('button', {
385 'class': 'cbi-button cbi-button-neutral',
386 'title': _('Reveal/hide password'),
387 'aria-label': _('Reveal/hide password'),
388 'click': function(ev) {
389 var e = this.previousElementSibling;
390 e.type = (e.type === 'password') ? 'text' : 'password';
391 ev.preventDefault();
392 }
393 }, '∗')
394 ]));
395
396 window.requestAnimationFrame(function() { inputEl.type = 'password' });
397 }
398 else {
399 frameEl.appendChild(inputEl);
400 }
401
402 return this.bind(frameEl);
403 },
404
405 /** @private */
406 bind: function(frameEl) {
407 var inputEl = frameEl.querySelector('input');
408
409 this.node = frameEl;
410
411 this.setUpdateEvents(inputEl, 'keyup', 'blur');
412 this.setChangeEvents(inputEl, 'change');
413
414 dom.bindClassInstance(frameEl, this);
415
416 return frameEl;
417 },
418
419 /** @override */
420 getValue: function() {
421 var inputEl = this.node.querySelector('input');
422 return inputEl.value;
423 },
424
425 /** @override */
426 setValue: function(value) {
427 var inputEl = this.node.querySelector('input');
428 inputEl.value = value;
429 }
430 });
431
432 /**
433 * Instantiate a textarea widget.
434 *
435 * @constructor Textarea
436 * @memberof LuCI.ui
437 * @augments LuCI.ui.AbstractElement
438 *
439 * @classdesc
440 *
441 * The `Textarea` class implements a multiline text area input field.
442 *
443 * UI widget instances are usually not supposed to be created by view code
444 * directly, instead they're implicitly created by `LuCI.form` when
445 * instantiating CBI forms.
446 *
447 * This class is automatically instantiated as part of `LuCI.ui`. To use it
448 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
449 * external JavaScript, use `L.require("ui").then(...)` and access the
450 * `Textarea` property of the class instance value.
451 *
452 * @param {string} [value=null]
453 * The initial input value.
454 *
455 * @param {LuCI.ui.Textarea.InitOptions} [options]
456 * Object describing the widget specific options to initialize the input.
457 */
458 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
459 /**
460 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
461 * the following properties are recognized:
462 *
463 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
464 * @memberof LuCI.ui.Textarea
465 *
466 * @property {boolean} [readonly=false]
467 * Specifies whether the input widget should be rendered readonly.
468 *
469 * @property {string} [placeholder]
470 * Specifies the HTML `placeholder` attribute which is displayed when the
471 * corresponding `<textarea>` element is empty.
472 *
473 * @property {boolean} [monospace=false]
474 * Specifies whether a monospace font should be forced for the textarea
475 * contents.
476 *
477 * @property {number} [cols]
478 * Specifies the HTML `cols` attribute to set on the corresponding
479 * `<textarea>` element.
480 *
481 * @property {number} [rows]
482 * Specifies the HTML `rows` attribute to set on the corresponding
483 * `<textarea>` element.
484 *
485 * @property {boolean} [wrap=false]
486 * Specifies whether the HTML `wrap` attribute should be set.
487 */
488 __init__: function(value, options) {
489 this.value = value;
490 this.options = Object.assign({
491 optional: true,
492 wrap: false,
493 cols: null,
494 rows: null
495 }, options);
496 },
497
498 /** @override */
499 render: function() {
500 var style = !this.options.cols ? 'width:100%' : null,
501 frameEl = E('div', { 'id': this.options.id, 'style': style }),
502 value = (this.value != null) ? String(this.value) : '';
503
504 frameEl.appendChild(E('textarea', {
505 'id': this.options.id ? 'widget.' + this.options.id : null,
506 'name': this.options.name,
507 'class': 'cbi-input-textarea',
508 'readonly': this.options.readonly ? '' : null,
509 'disabled': this.options.disabled ? '' : null,
510 'placeholder': this.options.placeholder,
511 'style': style,
512 'cols': this.options.cols,
513 'rows': this.options.rows,
514 'wrap': this.options.wrap ? '' : null
515 }, [ value ]));
516
517 if (this.options.monospace)
518 frameEl.firstElementChild.style.fontFamily = 'monospace';
519
520 return this.bind(frameEl);
521 },
522
523 /** @private */
524 bind: function(frameEl) {
525 var inputEl = frameEl.firstElementChild;
526
527 this.node = frameEl;
528
529 this.setUpdateEvents(inputEl, 'keyup', 'blur');
530 this.setChangeEvents(inputEl, 'change');
531
532 dom.bindClassInstance(frameEl, this);
533
534 return frameEl;
535 },
536
537 /** @override */
538 getValue: function() {
539 return this.node.firstElementChild.value;
540 },
541
542 /** @override */
543 setValue: function(value) {
544 this.node.firstElementChild.value = value;
545 }
546 });
547
548 /**
549 * Instantiate a checkbox widget.
550 *
551 * @constructor Checkbox
552 * @memberof LuCI.ui
553 * @augments LuCI.ui.AbstractElement
554 *
555 * @classdesc
556 *
557 * The `Checkbox` class implements a simple checkbox input field.
558 *
559 * UI widget instances are usually not supposed to be created by view code
560 * directly, instead they're implicitly created by `LuCI.form` when
561 * instantiating CBI forms.
562 *
563 * This class is automatically instantiated as part of `LuCI.ui`. To use it
564 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
565 * external JavaScript, use `L.require("ui").then(...)` and access the
566 * `Checkbox` property of the class instance value.
567 *
568 * @param {string} [value=null]
569 * The initial input value.
570 *
571 * @param {LuCI.ui.Checkbox.InitOptions} [options]
572 * Object describing the widget specific options to initialize the input.
573 */
574 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
575 /**
576 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
577 * the following properties are recognized:
578 *
579 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
580 * @memberof LuCI.ui.Checkbox
581 *
582 * @property {string} [value_enabled=1]
583 * Specifies the value corresponding to a checked checkbox.
584 *
585 * @property {string} [value_disabled=0]
586 * Specifies the value corresponding to an unchecked checkbox.
587 *
588 * @property {string} [hiddenname]
589 * Specifies the HTML `name` attribute of the hidden input backing the
590 * checkbox. This is a legacy property existing for compatibility reasons,
591 * it is required for HTML based form submissions.
592 */
593 __init__: function(value, options) {
594 this.value = value;
595 this.options = Object.assign({
596 value_enabled: '1',
597 value_disabled: '0'
598 }, options);
599 },
600
601 /** @override */
602 render: function() {
603 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
604 var frameEl = E('div', {
605 'id': this.options.id,
606 'class': 'cbi-checkbox'
607 });
608
609 if (this.options.hiddenname)
610 frameEl.appendChild(E('input', {
611 'type': 'hidden',
612 'name': this.options.hiddenname,
613 'value': 1
614 }));
615
616 frameEl.appendChild(E('input', {
617 'id': id,
618 'name': this.options.name,
619 'type': 'checkbox',
620 'value': this.options.value_enabled,
621 'checked': (this.value == this.options.value_enabled) ? '' : null,
622 'disabled': this.options.disabled ? '' : null,
623 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
624 }));
625
626 frameEl.appendChild(E('label', { 'for': id }));
627
628 if (this.options.tooltip != null) {
629 var icon = "⚠️";
630
631 if (this.options.tooltipicon != null)
632 icon = this.options.tooltipicon;
633
634 frameEl.appendChild(
635 E('label', { 'class': 'cbi-tooltip-container' },[
636 icon,
637 E('div', { 'class': 'cbi-tooltip' },
638 this.options.tooltip
639 )
640 ])
641 );
642 }
643
644 return this.bind(frameEl);
645 },
646
647 /** @private */
648 bind: function(frameEl) {
649 this.node = frameEl;
650
651 var input = frameEl.querySelector('input[type="checkbox"]');
652 this.setUpdateEvents(input, 'click', 'blur');
653 this.setChangeEvents(input, 'change');
654
655 dom.bindClassInstance(frameEl, this);
656
657 return frameEl;
658 },
659
660 /**
661 * Test whether the checkbox is currently checked.
662 *
663 * @instance
664 * @memberof LuCI.ui.Checkbox
665 * @returns {boolean}
666 * Returns `true` when the checkbox is currently checked, otherwise `false`.
667 */
668 isChecked: function() {
669 return this.node.querySelector('input[type="checkbox"]').checked;
670 },
671
672 /** @override */
673 getValue: function() {
674 return this.isChecked()
675 ? this.options.value_enabled
676 : this.options.value_disabled;
677 },
678
679 /** @override */
680 setValue: function(value) {
681 this.node.querySelector('input[type="checkbox"]').checked = (value == this.options.value_enabled);
682 }
683 });
684
685 /**
686 * Instantiate a select dropdown or checkbox/radiobutton group.
687 *
688 * @constructor Select
689 * @memberof LuCI.ui
690 * @augments LuCI.ui.AbstractElement
691 *
692 * @classdesc
693 *
694 * The `Select` class implements either a traditional HTML `<select>` element
695 * or a group of checkboxes or radio buttons, depending on whether multiple
696 * values are enabled or not.
697 *
698 * UI widget instances are usually not supposed to be created by view code
699 * directly, instead they're implicitly created by `LuCI.form` when
700 * instantiating CBI forms.
701 *
702 * This class is automatically instantiated as part of `LuCI.ui`. To use it
703 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
704 * external JavaScript, use `L.require("ui").then(...)` and access the
705 * `Select` property of the class instance value.
706 *
707 * @param {string|string[]} [value=null]
708 * The initial input value(s).
709 *
710 * @param {Object<string, string>} choices
711 * Object containing the selectable choices of the widget. The object keys
712 * serve as values for the different choices while the values are used as
713 * choice labels.
714 *
715 * @param {LuCI.ui.Select.InitOptions} [options]
716 * Object describing the widget specific options to initialize the inputs.
717 */
718 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
719 /**
720 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
721 * the following properties are recognized:
722 *
723 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
724 * @memberof LuCI.ui.Select
725 *
726 * @property {boolean} [multiple=false]
727 * Specifies whether multiple choice values may be selected.
728 *
729 * @property {"select"|"individual"} [widget=select]
730 * Specifies the kind of widget to render. May be either `select` or
731 * `individual`. When set to `select` an HTML `<select>` element will be
732 * used, otherwise a group of checkbox or radio button elements is created,
733 * depending on the value of the `multiple` option.
734 *
735 * @property {string} [orientation=horizontal]
736 * Specifies whether checkbox / radio button groups should be rendered
737 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
738 * widget type.
739 *
740 * @property {boolean|string[]} [sort=false]
741 * Specifies if and how to sort choice values. If set to `true`, the choice
742 * values will be sorted alphabetically. If set to an array of strings, the
743 * choice sort order is derived from the array.
744 *
745 * @property {number} [size]
746 * Specifies the HTML `size` attribute to set on the `<select>` element.
747 * Only applicable to the `select` widget type.
748 *
749 * @property {string} [placeholder=-- Please choose --]
750 * Specifies a placeholder text which is displayed when no choice is
751 * selected yet. Only applicable to the `select` widget type.
752 */
753 __init__: function(value, choices, options) {
754 if (!L.isObject(choices))
755 choices = {};
756
757 if (!Array.isArray(value))
758 value = (value != null && value != '') ? [ value ] : [];
759
760 if (!options.multiple && value.length > 1)
761 value.length = 1;
762
763 this.values = value;
764 this.choices = choices;
765 this.options = Object.assign({
766 multiple: false,
767 widget: 'select',
768 orientation: 'horizontal'
769 }, options);
770
771 if (this.choices.hasOwnProperty(''))
772 this.options.optional = true;
773 },
774
775 /** @override */
776 render: function() {
777 var frameEl = E('div', { 'id': this.options.id }),
778 keys = Object.keys(this.choices);
779
780 if (this.options.sort === true)
781 keys.sort(L.naturalCompare);
782 else if (Array.isArray(this.options.sort))
783 keys = this.options.sort;
784
785 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
786 frameEl.appendChild(E('select', {
787 'id': this.options.id ? 'widget.' + this.options.id : null,
788 'name': this.options.name,
789 'size': this.options.size,
790 'class': 'cbi-input-select',
791 'multiple': this.options.multiple ? '' : null,
792 'disabled': this.options.disabled ? '' : null
793 }));
794
795 if (this.options.optional)
796 frameEl.lastChild.appendChild(E('option', {
797 'value': '',
798 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
799 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
800
801 for (var i = 0; i < keys.length; i++) {
802 if (keys[i] == null || keys[i] == '')
803 continue;
804
805 frameEl.lastChild.appendChild(E('option', {
806 'value': keys[i],
807 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
808 }, [ this.choices[keys[i]] || keys[i] ]));
809 }
810 }
811 else {
812 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' \xa0 ') : E('br');
813
814 for (var i = 0; i < keys.length; i++) {
815 frameEl.appendChild(E('span', {
816 'class': 'cbi-%s'.format(this.options.multiple ? 'checkbox' : 'radio')
817 }, [
818 E('input', {
819 'id': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null,
820 'name': this.options.id || this.options.name,
821 'type': this.options.multiple ? 'checkbox' : 'radio',
822 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
823 'value': keys[i],
824 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
825 'disabled': this.options.disabled ? '' : null
826 }),
827 E('label', { 'for': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null }),
828 E('span', {
829 'click': function(ev) {
830 ev.currentTarget.previousElementSibling.previousElementSibling.click();
831 }
832 }, [ this.choices[keys[i]] || keys[i] ])
833 ]));
834
835 frameEl.appendChild(brEl.cloneNode());
836 }
837 }
838
839 return this.bind(frameEl);
840 },
841
842 /** @private */
843 bind: function(frameEl) {
844 this.node = frameEl;
845
846 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
847 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
848 this.setChangeEvents(frameEl.firstChild, 'change');
849 }
850 else {
851 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
852 for (var i = 0; i < radioEls.length; i++) {
853 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
854 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
855 }
856 }
857
858 dom.bindClassInstance(frameEl, this);
859
860 return frameEl;
861 },
862
863 /** @override */
864 getValue: function() {
865 if (this.options.widget != 'radio' && this.options.widget != 'checkbox')
866 return this.node.firstChild.value;
867
868 var radioEls = this.node.querySelectorAll('input[type="radio"]');
869 for (var i = 0; i < radioEls.length; i++)
870 if (radioEls[i].checked)
871 return radioEls[i].value;
872
873 return null;
874 },
875
876 /** @override */
877 setValue: function(value) {
878 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
879 if (value == null)
880 value = '';
881
882 for (var i = 0; i < this.node.firstChild.options.length; i++)
883 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
884
885 return;
886 }
887
888 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
889 for (var i = 0; i < radioEls.length; i++)
890 radioEls[i].checked = (radioEls[i].value == value);
891 }
892 });
893
894 /**
895 * Instantiate a rich dropdown choice widget.
896 *
897 * @constructor Dropdown
898 * @memberof LuCI.ui
899 * @augments LuCI.ui.AbstractElement
900 *
901 * @classdesc
902 *
903 * The `Dropdown` class implements a rich, stylable dropdown menu which
904 * supports non-text choice labels.
905 *
906 * UI widget instances are usually not supposed to be created by view code
907 * directly, instead they're implicitly created by `LuCI.form` when
908 * instantiating CBI forms.
909 *
910 * This class is automatically instantiated as part of `LuCI.ui`. To use it
911 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
912 * external JavaScript, use `L.require("ui").then(...)` and access the
913 * `Dropdown` property of the class instance value.
914 *
915 * @param {string|string[]} [value=null]
916 * The initial input value(s).
917 *
918 * @param {Object<string, *>} choices
919 * Object containing the selectable choices of the widget. The object keys
920 * serve as values for the different choices while the values are used as
921 * choice labels.
922 *
923 * @param {LuCI.ui.Dropdown.InitOptions} [options]
924 * Object describing the widget specific options to initialize the dropdown.
925 */
926 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
927 /**
928 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
929 * the following properties are recognized:
930 *
931 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
932 * @memberof LuCI.ui.Dropdown
933 *
934 * @property {boolean} [optional=true]
935 * Specifies whether the dropdown selection is optional. In contrast to
936 * other widgets, the `optional` constraint of dropdowns works differently;
937 * instead of marking the widget invalid on empty values when set to `false`,
938 * the user is not allowed to deselect all choices.
939 *
940 * For single value dropdowns that means that no empty "please select"
941 * choice is offered and for multi value dropdowns, the last selected choice
942 * may not be deselected without selecting another choice first.
943 *
944 * @property {boolean} [multiple]
945 * Specifies whether multiple choice values may be selected. It defaults
946 * to `true` when an array is passed as input value to the constructor.
947 *
948 * @property {boolean|string[]} [sort=false]
949 * Specifies if and how to sort choice values. If set to `true`, the choice
950 * values will be sorted alphabetically. If set to an array of strings, the
951 * choice sort order is derived from the array.
952 *
953 * @property {string} [select_placeholder=-- Please choose --]
954 * Specifies a placeholder text which is displayed when no choice is
955 * selected yet.
956 *
957 * @property {string} [custom_placeholder=-- custom --]
958 * Specifies a placeholder text which is displayed in the text input
959 * field allowing to enter custom choice values. Only applicable if the
960 * `create` option is set to `true`.
961 *
962 * @property {boolean} [create=false]
963 * Specifies whether custom choices may be entered into the dropdown
964 * widget.
965 *
966 * @property {string} [create_query=.create-item-input]
967 * Specifies a CSS selector expression used to find the input element
968 * which is used to enter custom choice values. This should not normally
969 * be used except by widgets derived from the Dropdown class.
970 *
971 * @property {string} [create_template=script[type="item-template"]]
972 * Specifies a CSS selector expression used to find an HTML element
973 * serving as template for newly added custom choice values.
974 *
975 * Any `{{value}}` placeholder string within the template elements text
976 * content will be replaced by the user supplied choice value, the
977 * resulting string is parsed as HTML and appended to the end of the
978 * choice list. The template markup may specify one HTML element with a
979 * `data-label-placeholder` attribute which is replaced by a matching
980 * label value from the `choices` object or with the user supplied value
981 * itself in case `choices` contains no matching choice label.
982 *
983 * If the template element is not found or if no `create_template` selector
984 * expression is specified, the default markup for newly created elements is
985 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
986 *
987 * @property {string} [create_markup]
988 * This property allows specifying the markup for custom choices directly
989 * instead of referring to a template element through CSS selectors.
990 *
991 * Apart from that it works exactly like `create_template`.
992 *
993 * @property {number} [display_items=3]
994 * Specifies the maximum amount of choice labels that should be shown in
995 * collapsed dropdown state before further selected choices are cut off.
996 *
997 * Only applicable when `multiple` is `true`.
998 *
999 * @property {number} [dropdown_items=-1]
1000 * Specifies the maximum amount of choices that should be shown when the
1001 * dropdown is open. If the amount of available choices exceeds this number,
1002 * the dropdown area must be scrolled to reach further items.
1003 *
1004 * If set to `-1`, the dropdown menu will attempt to show all choice values
1005 * and only resort to scrolling if the amount of choices exceeds the available
1006 * screen space above and below the dropdown widget.
1007 *
1008 * @property {string} [placeholder]
1009 * This property serves as a shortcut to set both `select_placeholder` and
1010 * `custom_placeholder`. Either of these properties will fallback to
1011 * `placeholder` if not specified.
1012 *
1013 * @property {boolean} [readonly=false]
1014 * Specifies whether the custom choice input field should be rendered
1015 * readonly. Only applicable when `create` is `true`.
1016 *
1017 * @property {number} [maxlength]
1018 * Specifies the HTML `maxlength` attribute to set on the custom choice
1019 * `<input>` element. Note that this a legacy property that exists for
1020 * compatibility reasons. It is usually better to `maxlength(N)` validation
1021 * expression. Only applicable when `create` is `true`.
1022 */
1023 __init__: function(value, choices, options) {
1024 if (typeof(choices) != 'object')
1025 choices = {};
1026
1027 if (!Array.isArray(value))
1028 this.values = (value != null && value != '') ? [ value ] : [];
1029 else
1030 this.values = value;
1031
1032 this.choices = choices;
1033 this.options = Object.assign({
1034 sort: true,
1035 multiple: Array.isArray(value),
1036 optional: true,
1037 select_placeholder: _('-- Please choose --'),
1038 custom_placeholder: _('-- custom --'),
1039 display_items: 3,
1040 dropdown_items: -1,
1041 create: false,
1042 create_query: '.create-item-input',
1043 create_template: 'script[type="item-template"]'
1044 }, options);
1045 },
1046
1047 /** @override */
1048 render: function() {
1049 var sb = E('div', {
1050 'id': this.options.id,
1051 'class': 'cbi-dropdown',
1052 'multiple': this.options.multiple ? '' : null,
1053 'optional': this.options.optional ? '' : null,
1054 'disabled': this.options.disabled ? '' : null,
1055 'tabindex': -1
1056 }, E('ul'));
1057
1058 var keys = Object.keys(this.choices);
1059
1060 if (this.options.sort === true)
1061 keys.sort(L.naturalCompare);
1062 else if (Array.isArray(this.options.sort))
1063 keys = this.options.sort;
1064
1065 if (this.options.create)
1066 for (var i = 0; i < this.values.length; i++)
1067 if (!this.choices.hasOwnProperty(this.values[i]))
1068 keys.push(this.values[i]);
1069
1070 for (var i = 0; i < keys.length; i++) {
1071 var label = this.choices[keys[i]];
1072
1073 if (dom.elem(label))
1074 label = label.cloneNode(true);
1075
1076 sb.lastElementChild.appendChild(E('li', {
1077 'data-value': keys[i],
1078 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
1079 }, [ label || keys[i] ]));
1080 }
1081
1082 if (this.options.create) {
1083 var createEl = E('input', {
1084 'type': 'text',
1085 'class': 'create-item-input',
1086 'readonly': this.options.readonly ? '' : null,
1087 'maxlength': this.options.maxlength,
1088 'placeholder': this.options.custom_placeholder || this.options.placeholder
1089 });
1090
1091 if (this.options.datatype || this.options.validate)
1092 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1093 true, this.options.validate, 'blur', 'keyup');
1094
1095 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1096 }
1097
1098 if (this.options.create_markup)
1099 sb.appendChild(E('script', { type: 'item-template' },
1100 this.options.create_markup));
1101
1102 return this.bind(sb);
1103 },
1104
1105 /** @private */
1106 bind: function(sb) {
1107 var o = this.options;
1108
1109 o.multiple = sb.hasAttribute('multiple');
1110 o.optional = sb.hasAttribute('optional');
1111 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1112 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1113 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1114 o.create_query = sb.getAttribute('item-create') || o.create_query;
1115 o.create_template = sb.getAttribute('item-template') || o.create_template;
1116
1117 var ul = sb.querySelector('ul'),
1118 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1119 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
1120 canary = sb.appendChild(E('div')),
1121 create = sb.querySelector(this.options.create_query),
1122 ndisplay = this.options.display_items,
1123 n = 0;
1124
1125 if (this.options.multiple) {
1126 var items = ul.querySelectorAll('li');
1127
1128 for (var i = 0; i < items.length; i++) {
1129 this.transformItem(sb, items[i]);
1130
1131 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1132 items[i].setAttribute('display', n++);
1133 }
1134 }
1135 else {
1136 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1137 var placeholder = E('li', { placeholder: '' },
1138 this.options.select_placeholder || this.options.placeholder);
1139
1140 ul.firstChild
1141 ? ul.insertBefore(placeholder, ul.firstChild)
1142 : ul.appendChild(placeholder);
1143 }
1144
1145 var items = ul.querySelectorAll('li'),
1146 sel = sb.querySelectorAll('[selected]');
1147
1148 sel.forEach(function(s) {
1149 s.removeAttribute('selected');
1150 });
1151
1152 var s = sel[0] || items[0];
1153 if (s) {
1154 s.setAttribute('selected', '');
1155 s.setAttribute('display', n++);
1156 }
1157
1158 ndisplay--;
1159 }
1160
1161 this.saveValues(sb, ul);
1162
1163 ul.setAttribute('tabindex', -1);
1164 sb.setAttribute('tabindex', 0);
1165
1166 if (ndisplay < 0)
1167 sb.setAttribute('more', '')
1168 else
1169 sb.removeAttribute('more');
1170
1171 if (ndisplay == this.options.display_items)
1172 sb.setAttribute('empty', '')
1173 else
1174 sb.removeAttribute('empty');
1175
1176 dom.content(more, (ndisplay == this.options.display_items)
1177 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1178
1179
1180 sb.addEventListener('click', this.handleClick.bind(this));
1181 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1182 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1183 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1184
1185 if ('ontouchstart' in window) {
1186 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1187 window.addEventListener('touchstart', this.closeAllDropdowns);
1188 }
1189 else {
1190 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1191 sb.addEventListener('mouseout', this.handleMouseout.bind(this));
1192 sb.addEventListener('focus', this.handleFocus.bind(this));
1193
1194 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1195
1196 window.addEventListener('click', this.closeAllDropdowns);
1197 }
1198
1199 if (create) {
1200 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1201 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1202 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1203
1204 var li = findParent(create, 'li');
1205
1206 li.setAttribute('unselectable', '');
1207 li.addEventListener('click', this.handleCreateClick.bind(this));
1208 }
1209
1210 this.node = sb;
1211
1212 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1213 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1214
1215 dom.bindClassInstance(sb, this);
1216
1217 return sb;
1218 },
1219
1220 /** @private */
1221 getScrollParent: function(element) {
1222 var parent = element,
1223 style = getComputedStyle(element),
1224 excludeStaticParent = (style.position === 'absolute');
1225
1226 if (style.position === 'fixed')
1227 return document.body;
1228
1229 while ((parent = parent.parentElement) != null) {
1230 style = getComputedStyle(parent);
1231
1232 if (excludeStaticParent && style.position === 'static')
1233 continue;
1234
1235 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1236 return parent;
1237 }
1238
1239 return document.body;
1240 },
1241
1242 /** @private */
1243 openDropdown: function(sb) {
1244 var st = window.getComputedStyle(sb, null),
1245 ul = sb.querySelector('ul'),
1246 li = ul.querySelectorAll('li'),
1247 fl = findParent(sb, '.cbi-value-field'),
1248 sel = ul.querySelector('[selected]'),
1249 rect = sb.getBoundingClientRect(),
1250 items = Math.min(this.options.dropdown_items, li.length),
1251 scrollParent = this.getScrollParent(sb);
1252
1253 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1254 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1255 });
1256
1257 sb.setAttribute('open', '');
1258
1259 var pv = ul.cloneNode(true);
1260 pv.classList.add('preview');
1261
1262 if (fl)
1263 fl.classList.add('cbi-dropdown-open');
1264
1265 if ('ontouchstart' in window) {
1266 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1267 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1268 start = null;
1269
1270 ul.style.top = sb.offsetHeight + 'px';
1271 ul.style.left = -rect.left + 'px';
1272 ul.style.right = (rect.right - vpWidth) + 'px';
1273 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1274 ul.style.WebkitOverflowScrolling = 'touch';
1275
1276 var scrollFrom = scrollParent.scrollTop,
1277 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1278
1279 var scrollStep = function(timestamp) {
1280 if (!start) {
1281 start = timestamp;
1282 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1283 }
1284
1285 var duration = Math.max(timestamp - start, 1);
1286 if (duration < 100) {
1287 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1288 window.requestAnimationFrame(scrollStep);
1289 }
1290 else {
1291 scrollParent.scrollTop = scrollTo;
1292 }
1293 };
1294
1295 window.requestAnimationFrame(scrollStep);
1296 }
1297 else {
1298 ul.style.maxHeight = '1px';
1299 ul.style.top = ul.style.bottom = '';
1300
1301 window.requestAnimationFrame(function() {
1302 var containerRect = scrollParent.getBoundingClientRect(),
1303 itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1304 fullHeight = 0,
1305 spaceAbove = rect.top - containerRect.top,
1306 spaceBelow = containerRect.bottom - rect.bottom;
1307
1308 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1309 fullHeight += li[i].getBoundingClientRect().height;
1310
1311 if (fullHeight <= spaceBelow) {
1312 ul.style.top = rect.height + 'px';
1313 ul.style.maxHeight = spaceBelow + 'px';
1314 }
1315 else if (fullHeight <= spaceAbove) {
1316 ul.style.bottom = rect.height + 'px';
1317 ul.style.maxHeight = spaceAbove + 'px';
1318 }
1319 else if (spaceBelow >= spaceAbove) {
1320 ul.style.top = rect.height + 'px';
1321 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1322 }
1323 else {
1324 ul.style.bottom = rect.height + 'px';
1325 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1326 }
1327
1328 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1329 });
1330 }
1331
1332 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1333 for (var i = 0; i < cboxes.length; i++) {
1334 cboxes[i].checked = true;
1335 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1336 };
1337
1338 ul.classList.add('dropdown');
1339
1340 sb.insertBefore(pv, ul.nextElementSibling);
1341
1342 li.forEach(function(l) {
1343 l.setAttribute('tabindex', 0);
1344 });
1345
1346 sb.lastElementChild.setAttribute('tabindex', 0);
1347
1348 var focusFn = L.bind(function(el) {
1349 this.setFocus(sb, el, true);
1350 ul.removeEventListener('transitionend', focusFn);
1351 }, this, sel || li[0]);
1352
1353 ul.addEventListener('transitionend', focusFn);
1354 },
1355
1356 /** @private */
1357 closeDropdown: function(sb, no_focus) {
1358 if (!sb.hasAttribute('open'))
1359 return;
1360
1361 var pv = sb.querySelector('ul.preview'),
1362 ul = sb.querySelector('ul.dropdown'),
1363 li = ul.querySelectorAll('li'),
1364 fl = findParent(sb, '.cbi-value-field');
1365
1366 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1367 sb.lastElementChild.removeAttribute('tabindex');
1368
1369 sb.removeChild(pv);
1370 sb.removeAttribute('open');
1371 sb.style.width = sb.style.height = '';
1372
1373 ul.classList.remove('dropdown');
1374 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1375
1376 if (fl)
1377 fl.classList.remove('cbi-dropdown-open');
1378
1379 if (!no_focus)
1380 this.setFocus(sb, sb);
1381
1382 this.saveValues(sb, ul);
1383 },
1384
1385 /** @private */
1386 toggleItem: function(sb, li, force_state) {
1387 var ul = li.parentNode;
1388
1389 if (li.hasAttribute('unselectable'))
1390 return;
1391
1392 if (this.options.multiple) {
1393 var cbox = li.querySelector('input[type="checkbox"]'),
1394 items = li.parentNode.querySelectorAll('li'),
1395 label = sb.querySelector('ul.preview'),
1396 sel = li.parentNode.querySelectorAll('[selected]').length,
1397 more = sb.querySelector('.more'),
1398 ndisplay = this.options.display_items,
1399 n = 0;
1400
1401 if (li.hasAttribute('selected')) {
1402 if (force_state !== true) {
1403 if (sel > 1 || this.options.optional) {
1404 li.removeAttribute('selected');
1405 cbox.checked = cbox.disabled = false;
1406 sel--;
1407 }
1408 else {
1409 cbox.disabled = true;
1410 }
1411 }
1412 }
1413 else {
1414 if (force_state !== false) {
1415 li.setAttribute('selected', '');
1416 cbox.checked = true;
1417 cbox.disabled = false;
1418 sel++;
1419 }
1420 }
1421
1422 while (label && label.firstElementChild)
1423 label.removeChild(label.firstElementChild);
1424
1425 for (var i = 0; i < items.length; i++) {
1426 items[i].removeAttribute('display');
1427 if (items[i].hasAttribute('selected')) {
1428 if (ndisplay-- > 0) {
1429 items[i].setAttribute('display', n++);
1430 if (label)
1431 label.appendChild(items[i].cloneNode(true));
1432 }
1433 var c = items[i].querySelector('input[type="checkbox"]');
1434 if (c)
1435 c.disabled = (sel == 1 && !this.options.optional);
1436 }
1437 }
1438
1439 if (ndisplay < 0)
1440 sb.setAttribute('more', '');
1441 else
1442 sb.removeAttribute('more');
1443
1444 if (ndisplay === this.options.display_items)
1445 sb.setAttribute('empty', '');
1446 else
1447 sb.removeAttribute('empty');
1448
1449 dom.content(more, (ndisplay === this.options.display_items)
1450 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1451 }
1452 else {
1453 var sel = li.parentNode.querySelector('[selected]');
1454 if (sel) {
1455 sel.removeAttribute('display');
1456 sel.removeAttribute('selected');
1457 }
1458
1459 li.setAttribute('display', 0);
1460 li.setAttribute('selected', '');
1461
1462 this.closeDropdown(sb, true);
1463 }
1464
1465 this.saveValues(sb, ul);
1466 },
1467
1468 /** @private */
1469 transformItem: function(sb, li) {
1470 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1471 label = E('label');
1472
1473 while (li.firstChild)
1474 label.appendChild(li.firstChild);
1475
1476 li.appendChild(cbox);
1477 li.appendChild(label);
1478 },
1479
1480 /** @private */
1481 saveValues: function(sb, ul) {
1482 var sel = ul.querySelectorAll('li[selected]'),
1483 div = sb.lastElementChild,
1484 name = this.options.name,
1485 strval = '',
1486 values = [];
1487
1488 while (div.lastElementChild)
1489 div.removeChild(div.lastElementChild);
1490
1491 sel.forEach(function (s) {
1492 if (s.hasAttribute('placeholder'))
1493 return;
1494
1495 var v = {
1496 text: s.innerText,
1497 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1498 element: s
1499 };
1500
1501 div.appendChild(E('input', {
1502 type: 'hidden',
1503 name: name,
1504 value: v.value
1505 }));
1506
1507 values.push(v);
1508
1509 strval += strval.length ? ' ' + v.value : v.value;
1510 });
1511
1512 var detail = {
1513 instance: this,
1514 element: sb
1515 };
1516
1517 if (this.options.multiple)
1518 detail.values = values;
1519 else
1520 detail.value = values.length ? values[0] : null;
1521
1522 sb.value = strval;
1523
1524 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1525 bubbles: true,
1526 detail: detail
1527 }));
1528 },
1529
1530 /** @private */
1531 setValues: function(sb, values) {
1532 var ul = sb.querySelector('ul');
1533
1534 if (this.options.create) {
1535 for (var value in values) {
1536 this.createItems(sb, value);
1537
1538 if (!this.options.multiple)
1539 break;
1540 }
1541 }
1542
1543 if (this.options.multiple) {
1544 var lis = ul.querySelectorAll('li[data-value]');
1545 for (var i = 0; i < lis.length; i++) {
1546 var value = lis[i].getAttribute('data-value');
1547 if (values === null || !(value in values))
1548 this.toggleItem(sb, lis[i], false);
1549 else
1550 this.toggleItem(sb, lis[i], true);
1551 }
1552 }
1553 else {
1554 var ph = ul.querySelector('li[placeholder]');
1555 if (ph)
1556 this.toggleItem(sb, ph);
1557
1558 var lis = ul.querySelectorAll('li[data-value]');
1559 for (var i = 0; i < lis.length; i++) {
1560 var value = lis[i].getAttribute('data-value');
1561 if (values !== null && (value in values))
1562 this.toggleItem(sb, lis[i]);
1563 }
1564 }
1565 },
1566
1567 /** @private */
1568 setFocus: function(sb, elem, scroll) {
1569 if (sb.hasAttribute('locked-in'))
1570 return;
1571
1572 sb.querySelectorAll('.focus').forEach(function(e) {
1573 e.classList.remove('focus');
1574 });
1575
1576 elem.classList.add('focus');
1577
1578 if (scroll)
1579 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1580
1581 elem.focus();
1582 },
1583
1584 /** @private */
1585 handleMouseout: function(ev) {
1586 var sb = ev.currentTarget;
1587
1588 if (!sb.hasAttribute('open'))
1589 return;
1590
1591 sb.querySelectorAll('.focus').forEach(function(e) {
1592 e.classList.remove('focus');
1593 });
1594
1595 sb.querySelector('ul.dropdown').focus();
1596 },
1597
1598 /** @private */
1599 createChoiceElement: function(sb, value, label) {
1600 var tpl = sb.querySelector(this.options.create_template),
1601 markup = null;
1602
1603 if (tpl)
1604 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|--!?>$/, '').trim();
1605 else
1606 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1607
1608 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1609 placeholder = new_item.querySelector('[data-label-placeholder]');
1610
1611 if (placeholder) {
1612 var content = E('span', {}, label || this.choices[value] || [ value ]);
1613
1614 while (content.firstChild)
1615 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1616
1617 placeholder.parentNode.removeChild(placeholder);
1618 }
1619
1620 if (this.options.multiple)
1621 this.transformItem(sb, new_item);
1622
1623 return new_item;
1624 },
1625
1626 /** @private */
1627 createItems: function(sb, value) {
1628 var sbox = this,
1629 val = (value || '').trim(),
1630 ul = sb.querySelector('ul');
1631
1632 if (!sbox.options.multiple)
1633 val = val.length ? [ val ] : [];
1634 else
1635 val = val.length ? val.split(/\s+/) : [];
1636
1637 val.forEach(function(item) {
1638 var new_item = null;
1639
1640 ul.childNodes.forEach(function(li) {
1641 if (li.getAttribute && li.getAttribute('data-value') === item)
1642 new_item = li;
1643 });
1644
1645 if (!new_item) {
1646 new_item = sbox.createChoiceElement(sb, item);
1647
1648 if (!sbox.options.multiple) {
1649 var old = ul.querySelector('li[created]');
1650 if (old)
1651 ul.removeChild(old);
1652
1653 new_item.setAttribute('created', '');
1654 }
1655
1656 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1657 }
1658
1659 sbox.toggleItem(sb, new_item, true);
1660 sbox.setFocus(sb, new_item, true);
1661 });
1662 },
1663
1664 /**
1665 * Remove all existing choices from the dropdown menu.
1666 *
1667 * This function removes all preexisting dropdown choices from the widget,
1668 * keeping only choices currently being selected unless `reset_values` is
1669 * given, in which case all choices and deselected and removed.
1670 *
1671 * @instance
1672 * @memberof LuCI.ui.Dropdown
1673 * @param {boolean} [reset_value=false]
1674 * If set to `true`, deselect and remove selected choices as well instead
1675 * of keeping them.
1676 */
1677 clearChoices: function(reset_value) {
1678 var ul = this.node.querySelector('ul'),
1679 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1680 len = lis.length - (this.options.create ? 1 : 0),
1681 val = reset_value ? null : this.getValue();
1682
1683 for (var i = 0; i < len; i++) {
1684 var lival = lis[i].getAttribute('data-value');
1685 if (val == null ||
1686 (!this.options.multiple && val != lival) ||
1687 (this.options.multiple && val.indexOf(lival) == -1))
1688 ul.removeChild(lis[i]);
1689 }
1690
1691 if (reset_value)
1692 this.setValues(this.node, {});
1693 },
1694
1695 /**
1696 * Add new choices to the dropdown menu.
1697 *
1698 * This function adds further choices to an existing dropdown menu,
1699 * ignoring choice values which are already present.
1700 *
1701 * @instance
1702 * @memberof LuCI.ui.Dropdown
1703 * @param {string[]} values
1704 * The choice values to add to the dropdown widget.
1705 *
1706 * @param {Object<string, *>} labels
1707 * The choice label values to use when adding dropdown choices. If no
1708 * label is found for a particular choice value, the value itself is used
1709 * as label text. Choice labels may be any valid value accepted by
1710 * {@link LuCI.dom#content}.
1711 */
1712 addChoices: function(values, labels) {
1713 var sb = this.node,
1714 ul = sb.querySelector('ul'),
1715 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1716
1717 if (!Array.isArray(values))
1718 values = L.toArray(values);
1719
1720 if (!L.isObject(labels))
1721 labels = {};
1722
1723 for (var i = 0; i < values.length; i++) {
1724 var found = false;
1725
1726 for (var j = 0; j < lis.length; j++) {
1727 if (lis[j].getAttribute('data-value') === values[i]) {
1728 found = true;
1729 break;
1730 }
1731 }
1732
1733 if (found)
1734 continue;
1735
1736 ul.insertBefore(
1737 this.createChoiceElement(sb, values[i], labels[values[i]]),
1738 ul.lastElementChild);
1739 }
1740 },
1741
1742 /**
1743 * Close all open dropdown widgets in the current document.
1744 */
1745 closeAllDropdowns: function() {
1746 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1747 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1748 });
1749 },
1750
1751 /** @private */
1752 handleClick: function(ev) {
1753 var sb = ev.currentTarget;
1754
1755 if (!sb.hasAttribute('open')) {
1756 if (!matchesElem(ev.target, 'input'))
1757 this.openDropdown(sb);
1758 }
1759 else {
1760 var li = findParent(ev.target, 'li');
1761 if (li && li.parentNode.classList.contains('dropdown'))
1762 this.toggleItem(sb, li);
1763 else if (li && li.parentNode.classList.contains('preview'))
1764 this.closeDropdown(sb);
1765 else if (matchesElem(ev.target, 'span.open, span.more'))
1766 this.closeDropdown(sb);
1767 }
1768
1769 ev.preventDefault();
1770 ev.stopPropagation();
1771 },
1772
1773 /** @private */
1774 handleKeydown: function(ev) {
1775 var sb = ev.currentTarget,
1776 ul = sb.querySelector('ul.dropdown');
1777
1778 if (matchesElem(ev.target, 'input'))
1779 return;
1780
1781 if (!sb.hasAttribute('open')) {
1782 switch (ev.keyCode) {
1783 case 37:
1784 case 38:
1785 case 39:
1786 case 40:
1787 this.openDropdown(sb);
1788 ev.preventDefault();
1789 }
1790 }
1791 else {
1792 var active = findParent(document.activeElement, 'li');
1793
1794 switch (ev.keyCode) {
1795 case 27:
1796 this.closeDropdown(sb);
1797 ev.stopPropagation();
1798 break;
1799
1800 case 13:
1801 if (active) {
1802 if (!active.hasAttribute('selected'))
1803 this.toggleItem(sb, active);
1804 this.closeDropdown(sb);
1805 ev.preventDefault();
1806 }
1807 break;
1808
1809 case 32:
1810 if (active) {
1811 this.toggleItem(sb, active);
1812 ev.preventDefault();
1813 }
1814 break;
1815
1816 case 38:
1817 if (active && active.previousElementSibling) {
1818 this.setFocus(sb, active.previousElementSibling);
1819 ev.preventDefault();
1820 }
1821 else if (document.activeElement === ul) {
1822 this.setFocus(sb, ul.lastElementChild);
1823 ev.preventDefault();
1824 }
1825 break;
1826
1827 case 40:
1828 if (active && active.nextElementSibling) {
1829 this.setFocus(sb, active.nextElementSibling);
1830 ev.preventDefault();
1831 }
1832 else if (document.activeElement === ul) {
1833 this.setFocus(sb, ul.firstElementChild);
1834 ev.preventDefault();
1835 }
1836 break;
1837 }
1838 }
1839 },
1840
1841 /** @private */
1842 handleDropdownClose: function(ev) {
1843 var sb = ev.currentTarget;
1844
1845 this.closeDropdown(sb, true);
1846 },
1847
1848 /** @private */
1849 handleDropdownSelect: function(ev) {
1850 var sb = ev.currentTarget,
1851 li = findParent(ev.target, 'li');
1852
1853 if (!li)
1854 return;
1855
1856 this.toggleItem(sb, li);
1857 this.closeDropdown(sb, true);
1858 },
1859
1860 /** @private */
1861 handleMouseover: function(ev) {
1862 var sb = ev.currentTarget;
1863
1864 if (!sb.hasAttribute('open'))
1865 return;
1866
1867 var li = findParent(ev.target, 'li');
1868
1869 if (li && li.parentNode.classList.contains('dropdown'))
1870 this.setFocus(sb, li);
1871 },
1872
1873 /** @private */
1874 handleFocus: function(ev) {
1875 var sb = ev.currentTarget;
1876
1877 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1878 if (s !== sb || sb.hasAttribute('open'))
1879 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1880 });
1881 },
1882
1883 /** @private */
1884 handleCanaryFocus: function(ev) {
1885 this.closeDropdown(ev.currentTarget.parentNode);
1886 },
1887
1888 /** @private */
1889 handleCreateKeydown: function(ev) {
1890 var input = ev.currentTarget,
1891 sb = findParent(input, '.cbi-dropdown');
1892
1893 switch (ev.keyCode) {
1894 case 13:
1895 ev.preventDefault();
1896
1897 if (input.classList.contains('cbi-input-invalid'))
1898 return;
1899
1900 this.createItems(sb, input.value);
1901 input.value = '';
1902 input.blur();
1903 break;
1904 }
1905 },
1906
1907 /** @private */
1908 handleCreateFocus: function(ev) {
1909 var input = ev.currentTarget,
1910 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1911 sb = findParent(input, '.cbi-dropdown');
1912
1913 if (cbox)
1914 cbox.checked = true;
1915
1916 sb.setAttribute('locked-in', '');
1917 },
1918
1919 /** @private */
1920 handleCreateBlur: function(ev) {
1921 var input = ev.currentTarget,
1922 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1923 sb = findParent(input, '.cbi-dropdown');
1924
1925 if (cbox)
1926 cbox.checked = false;
1927
1928 sb.removeAttribute('locked-in');
1929 },
1930
1931 /** @private */
1932 handleCreateClick: function(ev) {
1933 ev.currentTarget.querySelector(this.options.create_query).focus();
1934 },
1935
1936 /** @override */
1937 setValue: function(values) {
1938 if (this.options.multiple) {
1939 if (!Array.isArray(values))
1940 values = (values != null && values != '') ? [ values ] : [];
1941
1942 var v = {};
1943
1944 for (var i = 0; i < values.length; i++)
1945 v[values[i]] = true;
1946
1947 this.setValues(this.node, v);
1948 }
1949 else {
1950 var v = {};
1951
1952 if (values != null) {
1953 if (Array.isArray(values))
1954 v[values[0]] = true;
1955 else
1956 v[values] = true;
1957 }
1958
1959 this.setValues(this.node, v);
1960 }
1961 },
1962
1963 /** @override */
1964 getValue: function() {
1965 var div = this.node.lastElementChild,
1966 h = div.querySelectorAll('input[type="hidden"]'),
1967 v = [];
1968
1969 for (var i = 0; i < h.length; i++)
1970 v.push(h[i].value);
1971
1972 return this.options.multiple ? v : v[0];
1973 }
1974 });
1975
1976 /**
1977 * Instantiate a rich dropdown choice widget allowing custom values.
1978 *
1979 * @constructor Combobox
1980 * @memberof LuCI.ui
1981 * @augments LuCI.ui.Dropdown
1982 *
1983 * @classdesc
1984 *
1985 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1986 * to enter custom values. Historically, comboboxes used to be a dedicated
1987 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1988 * with a set of enforced default properties for easier instantiation.
1989 *
1990 * UI widget instances are usually not supposed to be created by view code
1991 * directly, instead they're implicitly created by `LuCI.form` when
1992 * instantiating CBI forms.
1993 *
1994 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1995 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1996 * external JavaScript, use `L.require("ui").then(...)` and access the
1997 * `Combobox` property of the class instance value.
1998 *
1999 * @param {string|string[]} [value=null]
2000 * The initial input value(s).
2001 *
2002 * @param {Object<string, *>} choices
2003 * Object containing the selectable choices of the widget. The object keys
2004 * serve as values for the different choices while the values are used as
2005 * choice labels.
2006 *
2007 * @param {LuCI.ui.Combobox.InitOptions} [options]
2008 * Object describing the widget specific options to initialize the dropdown.
2009 */
2010 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
2011 /**
2012 * Comboboxes support the same properties as
2013 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2014 * specific values for the following properties:
2015 *
2016 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2017 * @memberof LuCI.ui.Combobox
2018 *
2019 * @property {boolean} multiple=false
2020 * Since Comboboxes never allow selecting multiple values, this property
2021 * is forcibly set to `false`.
2022 *
2023 * @property {boolean} create=true
2024 * Since Comboboxes always allow custom choice values, this property is
2025 * forcibly set to `true`.
2026 *
2027 * @property {boolean} optional=true
2028 * Since Comboboxes are always optional, this property is forcibly set to
2029 * `true`.
2030 */
2031 __init__: function(value, choices, options) {
2032 this.super('__init__', [ value, choices, Object.assign({
2033 select_placeholder: _('-- Please choose --'),
2034 custom_placeholder: _('-- custom --'),
2035 dropdown_items: -1,
2036 sort: true
2037 }, options, {
2038 multiple: false,
2039 create: true,
2040 optional: true
2041 }) ]);
2042 }
2043 });
2044
2045 /**
2046 * Instantiate a combo button widget offering multiple action choices.
2047 *
2048 * @constructor ComboButton
2049 * @memberof LuCI.ui
2050 * @augments LuCI.ui.Dropdown
2051 *
2052 * @classdesc
2053 *
2054 * The `ComboButton` class implements a button element which can be expanded
2055 * into a dropdown to chose from a set of different action choices.
2056 *
2057 * UI widget instances are usually not supposed to be created by view code
2058 * directly, instead they're implicitly created by `LuCI.form` when
2059 * instantiating CBI forms.
2060 *
2061 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2062 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
2063 * external JavaScript, use `L.require("ui").then(...)` and access the
2064 * `ComboButton` property of the class instance value.
2065 *
2066 * @param {string|string[]} [value=null]
2067 * The initial input value(s).
2068 *
2069 * @param {Object<string, *>} choices
2070 * Object containing the selectable choices of the widget. The object keys
2071 * serve as values for the different choices while the values are used as
2072 * choice labels.
2073 *
2074 * @param {LuCI.ui.ComboButton.InitOptions} [options]
2075 * Object describing the widget specific options to initialize the button.
2076 */
2077 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
2078 /**
2079 * ComboButtons support the same properties as
2080 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2081 * specific values for some properties and add additional button specific
2082 * properties.
2083 *
2084 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2085 * @memberof LuCI.ui.ComboButton
2086 *
2087 * @property {boolean} multiple=false
2088 * Since ComboButtons never allow selecting multiple actions, this property
2089 * is forcibly set to `false`.
2090 *
2091 * @property {boolean} create=false
2092 * Since ComboButtons never allow creating custom choices, this property
2093 * is forcibly set to `false`.
2094 *
2095 * @property {boolean} optional=false
2096 * Since ComboButtons must always select one action, this property is
2097 * forcibly set to `false`.
2098 *
2099 * @property {Object<string, string>} [classes]
2100 * Specifies a mapping of choice values to CSS class names. If an action
2101 * choice is selected by the user and if a corresponding entry exists in
2102 * the `classes` object, the class names corresponding to the selected
2103 * value are set on the button element.
2104 *
2105 * This is useful to apply different button styles, such as colors, to the
2106 * combined button depending on the selected action.
2107 *
2108 * @property {function} [click]
2109 * Specifies a handler function to invoke when the user clicks the button.
2110 * This function will be called with the button DOM node as `this` context
2111 * and receive the DOM click event as first as well as the selected action
2112 * choice value as second argument.
2113 */
2114 __init__: function(value, choices, options) {
2115 this.super('__init__', [ value, choices, Object.assign({
2116 sort: true
2117 }, options, {
2118 multiple: false,
2119 create: false,
2120 optional: false
2121 }) ]);
2122 },
2123
2124 /** @override */
2125 render: function(/* ... */) {
2126 var node = UIDropdown.prototype.render.apply(this, arguments),
2127 val = this.getValue();
2128
2129 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2130 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2131
2132 return node;
2133 },
2134
2135 /** @private */
2136 handleClick: function(ev) {
2137 var sb = ev.currentTarget,
2138 t = ev.target;
2139
2140 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2141 return UIDropdown.prototype.handleClick.apply(this, arguments);
2142
2143 if (this.options.click)
2144 return this.options.click.call(sb, ev, this.getValue());
2145 },
2146
2147 /** @private */
2148 toggleItem: function(sb /*, ... */) {
2149 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2150 val = this.getValue();
2151
2152 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2153 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2154 else
2155 sb.setAttribute('class', 'cbi-dropdown');
2156
2157 return rv;
2158 }
2159 });
2160
2161 /**
2162 * Instantiate a dynamic list widget.
2163 *
2164 * @constructor DynamicList
2165 * @memberof LuCI.ui
2166 * @augments LuCI.ui.AbstractElement
2167 *
2168 * @classdesc
2169 *
2170 * The `DynamicList` class implements a widget which allows the user to specify
2171 * an arbitrary amount of input values, either from free formed text input or
2172 * from a set of predefined choices.
2173 *
2174 * UI widget instances are usually not supposed to be created by view code
2175 * directly, instead they're implicitly created by `LuCI.form` when
2176 * instantiating CBI forms.
2177 *
2178 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2179 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2180 * external JavaScript, use `L.require("ui").then(...)` and access the
2181 * `DynamicList` property of the class instance value.
2182 *
2183 * @param {string|string[]} [value=null]
2184 * The initial input value(s).
2185 *
2186 * @param {Object<string, *>} [choices]
2187 * Object containing the selectable choices of the widget. The object keys
2188 * serve as values for the different choices while the values are used as
2189 * choice labels. If omitted, no default choices are presented to the user,
2190 * instead a plain text input field is rendered allowing the user to add
2191 * arbitrary values to the dynamic list.
2192 *
2193 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2194 * Object describing the widget specific options to initialize the dynamic list.
2195 */
2196 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2197 /**
2198 * In case choices are passed to the dynamic list constructor, the widget
2199 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2200 * but enforces specific values for some dropdown properties.
2201 *
2202 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2203 * @memberof LuCI.ui.DynamicList
2204 *
2205 * @property {boolean} multiple=false
2206 * Since dynamic lists never allow selecting multiple choices when adding
2207 * another list item, this property is forcibly set to `false`.
2208 *
2209 * @property {boolean} optional=true
2210 * Since dynamic lists use an embedded dropdown to present a list of
2211 * predefined choice values, the dropdown must be made optional to allow
2212 * it to remain unselected.
2213 */
2214 __init__: function(values, choices, options) {
2215 if (!Array.isArray(values))
2216 values = (values != null && values != '') ? [ values ] : [];
2217
2218 if (typeof(choices) != 'object')
2219 choices = null;
2220
2221 this.values = values;
2222 this.choices = choices;
2223 this.options = Object.assign({}, options, {
2224 multiple: false,
2225 optional: true
2226 });
2227 },
2228
2229 /** @override */
2230 render: function() {
2231 var dl = E('div', {
2232 'id': this.options.id,
2233 'class': 'cbi-dynlist',
2234 'disabled': this.options.disabled ? '' : null
2235 }, E('div', { 'class': 'add-item control-group' }));
2236
2237 if (this.choices) {
2238 if (this.options.placeholder != null)
2239 this.options.select_placeholder = this.options.placeholder;
2240
2241 var cbox = new UICombobox(null, this.choices, this.options);
2242
2243 dl.lastElementChild.appendChild(cbox.render());
2244 }
2245 else {
2246 var inputEl = E('input', {
2247 'id': this.options.id ? 'widget.' + this.options.id : null,
2248 'type': 'text',
2249 'class': 'cbi-input-text',
2250 'placeholder': this.options.placeholder,
2251 'disabled': this.options.disabled ? '' : null
2252 });
2253
2254 dl.lastElementChild.appendChild(inputEl);
2255 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2256
2257 if (this.options.datatype || this.options.validate)
2258 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2259 true, this.options.validate, 'blur', 'keyup');
2260 }
2261
2262 for (var i = 0; i < this.values.length; i++) {
2263 var label = this.choices ? this.choices[this.values[i]] : null;
2264
2265 if (dom.elem(label))
2266 label = label.cloneNode(true);
2267
2268 this.addItem(dl, this.values[i], label);
2269 }
2270
2271 return this.bind(dl);
2272 },
2273
2274 /** @private */
2275 bind: function(dl) {
2276 dl.addEventListener('click', L.bind(this.handleClick, this));
2277 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2278 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2279
2280 this.node = dl;
2281
2282 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2283 this.setChangeEvents(dl, 'cbi-dynlist-change');
2284
2285 dom.bindClassInstance(dl, this);
2286
2287 return dl;
2288 },
2289
2290 /** @private */
2291 addItem: function(dl, value, text, flash) {
2292 var exists = false,
2293 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2294 E('span', {}, [ text || value ]),
2295 E('input', {
2296 'type': 'hidden',
2297 'name': this.options.name,
2298 'value': value })]);
2299
2300 dl.querySelectorAll('.item').forEach(function(item) {
2301 if (exists)
2302 return;
2303
2304 var hidden = item.querySelector('input[type="hidden"]');
2305
2306 if (hidden && hidden.parentNode !== item)
2307 hidden = null;
2308
2309 if (hidden && hidden.value === value)
2310 exists = true;
2311 });
2312
2313 if (!exists) {
2314 var ai = dl.querySelector('.add-item');
2315 ai.parentNode.insertBefore(new_item, ai);
2316 }
2317
2318 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2319 bubbles: true,
2320 detail: {
2321 instance: this,
2322 element: dl,
2323 value: value,
2324 add: true
2325 }
2326 }));
2327 },
2328
2329 /** @private */
2330 removeItem: function(dl, item) {
2331 var value = item.querySelector('input[type="hidden"]').value;
2332 var sb = dl.querySelector('.cbi-dropdown');
2333 if (sb)
2334 sb.querySelectorAll('ul > li').forEach(function(li) {
2335 if (li.getAttribute('data-value') === value) {
2336 if (li.hasAttribute('dynlistcustom'))
2337 li.parentNode.removeChild(li);
2338 else
2339 li.removeAttribute('unselectable');
2340 }
2341 });
2342
2343 item.parentNode.removeChild(item);
2344
2345 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2346 bubbles: true,
2347 detail: {
2348 instance: this,
2349 element: dl,
2350 value: value,
2351 remove: true
2352 }
2353 }));
2354 },
2355
2356 /** @private */
2357 handleClick: function(ev) {
2358 var dl = ev.currentTarget,
2359 item = findParent(ev.target, '.item');
2360
2361 if (this.options.disabled)
2362 return;
2363
2364 if (item) {
2365 this.removeItem(dl, item);
2366 }
2367 else if (matchesElem(ev.target, '.cbi-button-add')) {
2368 var input = ev.target.previousElementSibling;
2369 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2370 this.addItem(dl, input.value, null, true);
2371 input.value = '';
2372 }
2373 }
2374 },
2375
2376 /** @private */
2377 handleDropdownChange: function(ev) {
2378 var dl = ev.currentTarget,
2379 sbIn = ev.detail.instance,
2380 sbEl = ev.detail.element,
2381 sbVal = ev.detail.value;
2382
2383 if (sbVal === null)
2384 return;
2385
2386 sbIn.setValues(sbEl, null);
2387 sbVal.element.setAttribute('unselectable', '');
2388
2389 if (sbVal.element.hasAttribute('created')) {
2390 sbVal.element.removeAttribute('created');
2391 sbVal.element.setAttribute('dynlistcustom', '');
2392 }
2393
2394 var label = sbVal.text;
2395
2396 if (sbVal.element) {
2397 label = E([]);
2398
2399 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2400 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2401 }
2402
2403 this.addItem(dl, sbVal.value, label, true);
2404 },
2405
2406 /** @private */
2407 handleKeydown: function(ev) {
2408 var dl = ev.currentTarget,
2409 item = findParent(ev.target, '.item');
2410
2411 if (item) {
2412 switch (ev.keyCode) {
2413 case 8: /* backspace */
2414 if (item.previousElementSibling)
2415 item.previousElementSibling.focus();
2416
2417 this.removeItem(dl, item);
2418 break;
2419
2420 case 46: /* delete */
2421 if (item.nextElementSibling) {
2422 if (item.nextElementSibling.classList.contains('item'))
2423 item.nextElementSibling.focus();
2424 else
2425 item.nextElementSibling.firstElementChild.focus();
2426 }
2427
2428 this.removeItem(dl, item);
2429 break;
2430 }
2431 }
2432 else if (matchesElem(ev.target, '.cbi-input-text')) {
2433 switch (ev.keyCode) {
2434 case 13: /* enter */
2435 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2436 this.addItem(dl, ev.target.value, null, true);
2437 ev.target.value = '';
2438 ev.target.blur();
2439 ev.target.focus();
2440 }
2441
2442 ev.preventDefault();
2443 break;
2444 }
2445 }
2446 },
2447
2448 /** @override */
2449 getValue: function() {
2450 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2451 input = this.node.querySelector('.add-item > input[type="text"]'),
2452 v = [];
2453
2454 for (var i = 0; i < items.length; i++)
2455 v.push(items[i].value);
2456
2457 if (input && input.value != null && input.value.match(/\S/) &&
2458 input.classList.contains('cbi-input-invalid') == false &&
2459 v.filter(function(s) { return s == input.value }).length == 0)
2460 v.push(input.value);
2461
2462 return v;
2463 },
2464
2465 /** @override */
2466 setValue: function(values) {
2467 if (!Array.isArray(values))
2468 values = (values != null && values != '') ? [ values ] : [];
2469
2470 var items = this.node.querySelectorAll('.item');
2471
2472 for (var i = 0; i < items.length; i++)
2473 if (items[i].parentNode === this.node)
2474 this.removeItem(this.node, items[i]);
2475
2476 for (var i = 0; i < values.length; i++)
2477 this.addItem(this.node, values[i],
2478 this.choices ? this.choices[values[i]] : null);
2479 },
2480
2481 /**
2482 * Add new suggested choices to the dynamic list.
2483 *
2484 * This function adds further choices to an existing dynamic list,
2485 * ignoring choice values which are already present.
2486 *
2487 * @instance
2488 * @memberof LuCI.ui.DynamicList
2489 * @param {string[]} values
2490 * The choice values to add to the dynamic lists suggestion dropdown.
2491 *
2492 * @param {Object<string, *>} labels
2493 * The choice label values to use when adding suggested choices. If no
2494 * label is found for a particular choice value, the value itself is used
2495 * as label text. Choice labels may be any valid value accepted by
2496 * {@link LuCI.dom#content}.
2497 */
2498 addChoices: function(values, labels) {
2499 var dl = this.node.lastElementChild.firstElementChild;
2500 dom.callClassMethod(dl, 'addChoices', values, labels);
2501 },
2502
2503 /**
2504 * Remove all existing choices from the dynamic list.
2505 *
2506 * This function removes all preexisting suggested choices from the widget.
2507 *
2508 * @instance
2509 * @memberof LuCI.ui.DynamicList
2510 */
2511 clearChoices: function() {
2512 var dl = this.node.lastElementChild.firstElementChild;
2513 dom.callClassMethod(dl, 'clearChoices');
2514 }
2515 });
2516
2517 /**
2518 * Instantiate a hidden input field widget.
2519 *
2520 * @constructor Hiddenfield
2521 * @memberof LuCI.ui
2522 * @augments LuCI.ui.AbstractElement
2523 *
2524 * @classdesc
2525 *
2526 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2527 * which allows to store form data without exposing it to the user.
2528 *
2529 * UI widget instances are usually not supposed to be created by view code
2530 * directly, instead they're implicitly created by `LuCI.form` when
2531 * instantiating CBI forms.
2532 *
2533 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2534 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2535 * external JavaScript, use `L.require("ui").then(...)` and access the
2536 * `Hiddenfield` property of the class instance value.
2537 *
2538 * @param {string|string[]} [value=null]
2539 * The initial input value.
2540 *
2541 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2542 * Object describing the widget specific options to initialize the hidden input.
2543 */
2544 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2545 __init__: function(value, options) {
2546 this.value = value;
2547 this.options = Object.assign({
2548
2549 }, options);
2550 },
2551
2552 /** @override */
2553 render: function() {
2554 var hiddenEl = E('input', {
2555 'id': this.options.id,
2556 'type': 'hidden',
2557 'value': this.value
2558 });
2559
2560 return this.bind(hiddenEl);
2561 },
2562
2563 /** @private */
2564 bind: function(hiddenEl) {
2565 this.node = hiddenEl;
2566
2567 dom.bindClassInstance(hiddenEl, this);
2568
2569 return hiddenEl;
2570 },
2571
2572 /** @override */
2573 getValue: function() {
2574 return this.node.value;
2575 },
2576
2577 /** @override */
2578 setValue: function(value) {
2579 this.node.value = value;
2580 }
2581 });
2582
2583 /**
2584 * Instantiate a file upload widget.
2585 *
2586 * @constructor FileUpload
2587 * @memberof LuCI.ui
2588 * @augments LuCI.ui.AbstractElement
2589 *
2590 * @classdesc
2591 *
2592 * The `FileUpload` class implements a widget which allows the user to upload,
2593 * browse, select and delete files beneath a predefined remote directory.
2594 *
2595 * UI widget instances are usually not supposed to be created by view code
2596 * directly, instead they're implicitly created by `LuCI.form` when
2597 * instantiating CBI forms.
2598 *
2599 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2600 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2601 * external JavaScript, use `L.require("ui").then(...)` and access the
2602 * `FileUpload` property of the class instance value.
2603 *
2604 * @param {string|string[]} [value=null]
2605 * The initial input value.
2606 *
2607 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2608 * Object describing the widget specific options to initialize the file
2609 * upload control.
2610 */
2611 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2612 /**
2613 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2614 * the following properties are recognized:
2615 *
2616 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2617 * @memberof LuCI.ui.FileUpload
2618 *
2619 * @property {boolean} [show_hidden=false]
2620 * Specifies whether hidden files should be displayed when browsing remote
2621 * files. Note that this is not a security feature, hidden files are always
2622 * present in the remote file listings received, this option merely controls
2623 * whether they're displayed or not.
2624 *
2625 * @property {boolean} [enable_upload=true]
2626 * Specifies whether the widget allows the user to upload files. If set to
2627 * `false`, only existing files may be selected. Note that this is not a
2628 * security feature. Whether file upload requests are accepted remotely
2629 * depends on the ACL setup for the current session. This option merely
2630 * controls whether the upload controls are rendered or not.
2631 *
2632 * @property {boolean} [enable_remove=true]
2633 * Specifies whether the widget allows the user to delete remove files.
2634 * If set to `false`, existing files may not be removed. Note that this is
2635 * not a security feature. Whether file delete requests are accepted
2636 * remotely depends on the ACL setup for the current session. This option
2637 * merely controls whether the file remove controls are rendered or not.
2638 *
2639 * @property {string} [root_directory=/etc/luci-uploads]
2640 * Specifies the remote directory the upload and file browsing actions take
2641 * place in. Browsing to directories outside the root directory is
2642 * prevented by the widget. Note that this is not a security feature.
2643 * Whether remote directories are browsable or not solely depends on the
2644 * ACL setup for the current session.
2645 */
2646 __init__: function(value, options) {
2647 this.value = value;
2648 this.options = Object.assign({
2649 show_hidden: false,
2650 enable_upload: true,
2651 enable_remove: true,
2652 root_directory: '/etc/luci-uploads'
2653 }, options);
2654 },
2655
2656 /** @private */
2657 bind: function(browserEl) {
2658 this.node = browserEl;
2659
2660 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2661 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2662
2663 dom.bindClassInstance(browserEl, this);
2664
2665 return browserEl;
2666 },
2667
2668 /** @override */
2669 render: function() {
2670 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2671 var label;
2672
2673 if (L.isObject(stat) && stat.type != 'directory')
2674 this.stat = stat;
2675
2676 if (this.stat != null)
2677 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2678 else if (this.value != null)
2679 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2680 else
2681 label = [ _('Select file…') ];
2682
2683 return this.bind(E('div', { 'id': this.options.id }, [
2684 E('button', {
2685 'class': 'btn',
2686 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2687 'disabled': this.options.disabled ? '' : null
2688 }, label),
2689 E('div', {
2690 'class': 'cbi-filebrowser'
2691 }),
2692 E('input', {
2693 'type': 'hidden',
2694 'name': this.options.name,
2695 'value': this.value
2696 })
2697 ]));
2698 }, this));
2699 },
2700
2701 /** @private */
2702 truncatePath: function(path) {
2703 if (path.length > 50)
2704 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2705
2706 return path;
2707 },
2708
2709 /** @private */
2710 iconForType: function(type) {
2711 switch (type) {
2712 case 'symlink':
2713 return E('img', {
2714 'src': L.resource('cbi/link.svg'),
2715 'width': 16,
2716 'title': _('Symbolic link'),
2717 'class': 'middle'
2718 });
2719
2720 case 'directory':
2721 return E('img', {
2722 'src': L.resource('cbi/folder.svg'),
2723 'width': 16,
2724 'title': _('Directory'),
2725 'class': 'middle'
2726 });
2727
2728 default:
2729 return E('img', {
2730 'src': L.resource('cbi/file.svg'),
2731 'width': 16,
2732 'title': _('File'),
2733 'class': 'middle'
2734 });
2735 }
2736 },
2737
2738 /** @private */
2739 canonicalizePath: function(path) {
2740 return path.replace(/\/{2,}/, '/')
2741 .replace(/\/\.(\/|$)/g, '/')
2742 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2743 .replace(/\/$/, '');
2744 },
2745
2746 /** @private */
2747 splitPath: function(path) {
2748 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2749 cpath = this.canonicalizePath(path || '/');
2750
2751 if (cpath.length <= croot.length)
2752 return [ croot ];
2753
2754 if (cpath.charAt(croot.length) != '/')
2755 return [ croot ];
2756
2757 var parts = cpath.substring(croot.length + 1).split(/\//);
2758
2759 parts.unshift(croot);
2760
2761 return parts;
2762 },
2763
2764 /** @private */
2765 handleUpload: function(path, list, ev) {
2766 var form = ev.target.parentNode,
2767 fileinput = form.querySelector('input[type="file"]'),
2768 nameinput = form.querySelector('input[type="text"]'),
2769 filename = (nameinput.value != null ? nameinput.value : '').trim();
2770
2771 ev.preventDefault();
2772
2773 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2774 return;
2775
2776 var existing = list.filter(function(e) { return e.name == filename })[0];
2777
2778 if (existing != null && existing.type == 'directory')
2779 return alert(_('A directory with the same name already exists.'));
2780 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2781 return;
2782
2783 var data = new FormData();
2784
2785 data.append('sessionid', L.env.sessionid);
2786 data.append('filename', path + '/' + filename);
2787 data.append('filedata', fileinput.files[0]);
2788
2789 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2790 progress: L.bind(function(btn, ev) {
2791 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2792 }, this, ev.target)
2793 }).then(L.bind(function(path, ev, res) {
2794 var reply = res.json();
2795
2796 if (L.isObject(reply) && reply.failure)
2797 alert(_('Upload request failed: %s').format(reply.message));
2798
2799 return this.handleSelect(path, null, ev);
2800 }, this, path, ev));
2801 },
2802
2803 /** @private */
2804 handleDelete: function(path, fileStat, ev) {
2805 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2806 name = path.replace(/^.+\//, ''),
2807 msg;
2808
2809 ev.preventDefault();
2810
2811 if (fileStat.type == 'directory')
2812 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2813 else
2814 msg = _('Do you really want to delete "%s" ?').format(name);
2815
2816 if (confirm(msg)) {
2817 var button = this.node.firstElementChild,
2818 hidden = this.node.lastElementChild;
2819
2820 if (path == hidden.value) {
2821 dom.content(button, _('Select file…'));
2822 hidden.value = '';
2823 }
2824
2825 return fs.remove(path).then(L.bind(function(parent, ev) {
2826 return this.handleSelect(parent, null, ev);
2827 }, this, parent, ev)).catch(function(err) {
2828 alert(_('Delete request failed: %s').format(err.message));
2829 });
2830 }
2831 },
2832
2833 /** @private */
2834 renderUpload: function(path, list) {
2835 if (!this.options.enable_upload)
2836 return E([]);
2837
2838 return E([
2839 E('a', {
2840 'href': '#',
2841 'class': 'btn cbi-button-positive',
2842 'click': function(ev) {
2843 var uploadForm = ev.target.nextElementSibling,
2844 fileInput = uploadForm.querySelector('input[type="file"]');
2845
2846 ev.target.style.display = 'none';
2847 uploadForm.style.display = '';
2848 fileInput.click();
2849 }
2850 }, _('Upload file…')),
2851 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2852 E('input', {
2853 'type': 'file',
2854 'style': 'display:none',
2855 'change': function(ev) {
2856 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2857 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2858
2859 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2860 uploadbtn.disabled = false;
2861 }
2862 }),
2863 E('button', {
2864 'class': 'btn',
2865 'click': function(ev) {
2866 ev.preventDefault();
2867 ev.target.previousElementSibling.click();
2868 }
2869 }, [ _('Browse…') ]),
2870 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2871 E('button', {
2872 'class': 'btn cbi-button-save',
2873 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2874 'disabled': true
2875 }, [ _('Upload file') ])
2876 ])
2877 ]);
2878 },
2879
2880 /** @private */
2881 renderListing: function(container, path, list) {
2882 var breadcrumb = E('p'),
2883 rows = E('ul');
2884
2885 list.sort(function(a, b) {
2886 return L.naturalCompare(a.type == 'directory', b.type == 'directory') ||
2887 L.naturalCompare(a.name, b.name);
2888 });
2889
2890 for (var i = 0; i < list.length; i++) {
2891 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2892 continue;
2893
2894 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2895 selected = (entrypath == this.node.lastElementChild.value),
2896 mtime = new Date(list[i].mtime * 1000);
2897
2898 rows.appendChild(E('li', [
2899 E('div', { 'class': 'name' }, [
2900 this.iconForType(list[i].type),
2901 ' ',
2902 E('a', {
2903 'href': '#',
2904 'style': selected ? 'font-weight:bold' : null,
2905 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2906 entrypath, list[i].type != 'directory' ? list[i] : null)
2907 }, '%h'.format(list[i].name))
2908 ]),
2909 E('div', { 'class': 'mtime hide-xs' }, [
2910 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2911 mtime.getFullYear(),
2912 mtime.getMonth() + 1,
2913 mtime.getDate(),
2914 mtime.getHours(),
2915 mtime.getMinutes(),
2916 mtime.getSeconds())
2917 ]),
2918 E('div', [
2919 selected ? E('button', {
2920 'class': 'btn',
2921 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2922 }, [ _('Deselect') ]) : '',
2923 this.options.enable_remove ? E('button', {
2924 'class': 'btn cbi-button-negative',
2925 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2926 }, [ _('Delete') ]) : ''
2927 ])
2928 ]));
2929 }
2930
2931 if (!rows.firstElementChild)
2932 rows.appendChild(E('em', _('No entries in this directory')));
2933
2934 var dirs = this.splitPath(path),
2935 cur = '';
2936
2937 for (var i = 0; i < dirs.length; i++) {
2938 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2939 dom.append(breadcrumb, [
2940 i ? ' » ' : '',
2941 E('a', {
2942 'href': '#',
2943 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2944 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2945 ]);
2946 }
2947
2948 dom.content(container, [
2949 breadcrumb,
2950 rows,
2951 E('div', { 'class': 'right' }, [
2952 this.renderUpload(path, list),
2953 E('a', {
2954 'href': '#',
2955 'class': 'btn',
2956 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2957 }, _('Cancel'))
2958 ]),
2959 ]);
2960 },
2961
2962 /** @private */
2963 handleCancel: function(ev) {
2964 var button = this.node.firstElementChild,
2965 browser = button.nextElementSibling;
2966
2967 browser.classList.remove('open');
2968 button.style.display = '';
2969
2970 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2971
2972 ev.preventDefault();
2973 },
2974
2975 /** @private */
2976 handleReset: function(ev) {
2977 var button = this.node.firstElementChild,
2978 hidden = this.node.lastElementChild;
2979
2980 hidden.value = '';
2981 dom.content(button, _('Select file…'));
2982
2983 this.handleCancel(ev);
2984 },
2985
2986 /** @private */
2987 handleSelect: function(path, fileStat, ev) {
2988 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2989 ul = browser.querySelector('ul');
2990
2991 if (fileStat == null) {
2992 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2993 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2994 }
2995 else {
2996 var button = this.node.firstElementChild,
2997 hidden = this.node.lastElementChild;
2998
2999 path = this.canonicalizePath(path);
3000
3001 dom.content(button, [
3002 this.iconForType(fileStat.type),
3003 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
3004 ]);
3005
3006 browser.classList.remove('open');
3007 button.style.display = '';
3008 hidden.value = path;
3009
3010 this.stat = Object.assign({ path: path }, fileStat);
3011 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
3012 }
3013 },
3014
3015 /** @private */
3016 handleFileBrowser: function(ev) {
3017 var button = ev.target,
3018 browser = button.nextElementSibling,
3019 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
3020
3021 if (path.indexOf(this.options.root_directory) != 0)
3022 path = this.options.root_directory;
3023
3024 ev.preventDefault();
3025
3026 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
3027 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
3028 dom.findClassInstance(browserEl).handleCancel(ev);
3029 });
3030
3031 button.style.display = 'none';
3032 browser.classList.add('open');
3033
3034 return this.renderListing(browser, path, list);
3035 }, this, button, browser, path));
3036 },
3037
3038 /** @override */
3039 getValue: function() {
3040 return this.node.lastElementChild.value;
3041 },
3042
3043 /** @override */
3044 setValue: function(value) {
3045 this.node.lastElementChild.value = value;
3046 }
3047 });
3048
3049
3050 function scrubMenu(node) {
3051 var hasSatisfiedChild = false;
3052
3053 if (L.isObject(node.children)) {
3054 for (var k in node.children) {
3055 var child = scrubMenu(node.children[k]);
3056
3057 if (child.title && !child.firstchild_ineligible)
3058 hasSatisfiedChild = hasSatisfiedChild || child.satisfied;
3059 }
3060 }
3061
3062 if (L.isObject(node.action) &&
3063 node.action.type == 'firstchild' &&
3064 hasSatisfiedChild == false)
3065 node.satisfied = false;
3066
3067 return node;
3068 };
3069
3070 /**
3071 * Handle menu.
3072 *
3073 * @constructor menu
3074 * @memberof LuCI.ui
3075 *
3076 * @classdesc
3077 *
3078 * Handles menus.
3079 */
3080 var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
3081 /**
3082 * @typedef {Object} MenuNode
3083 * @memberof LuCI.ui.menu
3084
3085 * @property {string} name - The internal name of the node, as used in the URL
3086 * @property {number} order - The sort index of the menu node
3087 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
3088 * @property {satisfied} boolean - Boolean indicating whether the menu entries dependencies are satisfied
3089 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
3090 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
3091 */
3092
3093 /**
3094 * Load and cache current menu tree.
3095 *
3096 * @returns {Promise<LuCI.ui.menu.MenuNode>}
3097 * Returns a promise resolving to the root element of the menu tree.
3098 */
3099 load: function() {
3100 if (this.menu == null)
3101 this.menu = session.getLocalData('menu');
3102
3103 if (!L.isObject(this.menu)) {
3104 this.menu = request.get(L.url('admin/menu')).then(L.bind(function(menu) {
3105 this.menu = scrubMenu(menu.json());
3106 session.setLocalData('menu', this.menu);
3107
3108 return this.menu;
3109 }, this));
3110 }
3111
3112 return Promise.resolve(this.menu);
3113 },
3114
3115 /**
3116 * Flush the internal menu cache to force loading a new structure on the
3117 * next page load.
3118 */
3119 flushCache: function() {
3120 session.setLocalData('menu', null);
3121 },
3122
3123 /**
3124 * @param {LuCI.ui.menu.MenuNode} [node]
3125 * The menu node to retrieve the children for. Defaults to the menu's
3126 * internal root node if omitted.
3127 *
3128 * @returns {LuCI.ui.menu.MenuNode[]}
3129 * Returns an array of child menu nodes.
3130 */
3131 getChildren: function(node) {
3132 var children = [];
3133
3134 if (node == null)
3135 node = this.menu;
3136
3137 for (var k in node.children) {
3138 if (!node.children.hasOwnProperty(k))
3139 continue;
3140
3141 if (!node.children[k].satisfied)
3142 continue;
3143
3144 if (!node.children[k].hasOwnProperty('title'))
3145 continue;
3146
3147 var subnode = Object.assign(node.children[k], { name: k });
3148
3149 if (L.isObject(subnode.action) && subnode.action.path != null &&
3150 (subnode.action.type == 'alias' || subnode.action.type == 'rewrite')) {
3151 var root = this.menu,
3152 path = subnode.action.path.split('/');
3153
3154 for (var i = 0; root != null && i < path.length; i++)
3155 root = L.isObject(root.children) ? root.children[path[i]] : null;
3156
3157 if (root)
3158 subnode = Object.assign({}, subnode, {
3159 children: root.children,
3160 action: root.action
3161 });
3162 }
3163
3164 children.push(subnode);
3165 }
3166
3167 return children.sort(function(a, b) {
3168 var wA = a.order || 1000,
3169 wB = b.order || 1000;
3170
3171 if (wA != wB)
3172 return wA - wB;
3173
3174 return L.naturalCompare(a.name, b.name);
3175 });
3176 }
3177 });
3178
3179 var UITable = baseclass.extend(/** @lends LuCI.ui.table.prototype */ {
3180 __init__: function(captions, options, placeholder) {
3181 if (!Array.isArray(captions)) {
3182 this.initFromMarkup(captions);
3183
3184 return;
3185 }
3186
3187 var id = options.id || 'table%08x'.format(Math.random() * 0xffffffff);
3188
3189 var table = E('table', { 'id': id, 'class': 'table' }, [
3190 E('tr', { 'class': 'tr table-titles', 'click': UI.prototype.createHandlerFn(this, 'handleSort') })
3191 ]);
3192
3193 this.id = id;
3194 this.node = table
3195 this.options = options;
3196
3197 var sorting = this.getActiveSortState();
3198
3199 for (var i = 0; i < captions.length; i++) {
3200 if (captions[i] == null)
3201 continue;
3202
3203 var th = E('th', { 'class': 'th' }, [ captions[i] ]);
3204
3205 if (typeof(options.captionClasses) == 'object')
3206 DOMTokenList.prototype.add.apply(th.classList, L.toArray(options.captionClasses[i]));
3207
3208 if (options.sortable !== false && (typeof(options.sortable) != 'object' || options.sortable[i] !== false)) {
3209 th.setAttribute('data-sortable-row', true);
3210
3211 if (sorting && sorting[0] == i)
3212 th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc');
3213 }
3214
3215 table.firstElementChild.appendChild(th);
3216 }
3217
3218 if (placeholder) {
3219 var trow = table.appendChild(E('tr', { 'class': 'tr placeholder' })),
3220 td = trow.appendChild(E('td', { 'class': 'td' }, placeholder));
3221
3222 if (typeof(captionClasses) == 'object')
3223 DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
3224 }
3225
3226 DOMTokenList.prototype.add.apply(table.classList, L.toArray(options.classes));
3227 },
3228
3229 update: function(data, placeholder) {
3230 var placeholder = placeholder || this.options.placeholder || _('No data', 'empty table placeholder'),
3231 sorting = this.getActiveSortState();
3232
3233 if (!Array.isArray(data))
3234 return;
3235
3236 this.data = data;
3237 this.placeholder = placeholder;
3238
3239 var n = 0,
3240 rows = this.node.querySelectorAll('tr, .tr'),
3241 trows = [],
3242 headings = [].slice.call(this.node.firstElementChild.querySelectorAll('th, .th')),
3243 captionClasses = this.options.captionClasses,
3244 trTag = (rows[0] && rows[0].nodeName == 'DIV') ? 'div' : 'tr',
3245 tdTag = (headings[0] && headings[0].nodeName == 'DIV') ? 'div' : 'td';
3246
3247 if (sorting) {
3248 var list = data.map(L.bind(function(row) {
3249 return [ this.deriveSortKey(row[sorting[0]], sorting[0]), row ];
3250 }, this));
3251
3252 list.sort(function(a, b) {
3253 return sorting[1]
3254 ? -L.naturalCompare(a[0], b[0])
3255 : L.naturalCompare(a[0], b[0]);
3256 });
3257
3258 data.length = 0;
3259
3260 list.forEach(function(item) {
3261 data.push(item[1]);
3262 });
3263
3264 headings.forEach(function(th, i) {
3265 if (i == sorting[0])
3266 th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc');
3267 else
3268 th.removeAttribute('data-sort-direction');
3269 });
3270 }
3271
3272 data.forEach(function(row) {
3273 trows[n] = E(trTag, { 'class': 'tr' });
3274
3275 for (var i = 0; i < headings.length; i++) {
3276 var text = (headings[i].innerText || '').trim();
3277 var raw_val = Array.isArray(row[i]) ? row[i][0] : null;
3278 var disp_val = Array.isArray(row[i]) ? row[i][1] : row[i];
3279 var td = trows[n].appendChild(E(tdTag, {
3280 'class': 'td',
3281 'data-title': (text !== '') ? text : null,
3282 'data-value': raw_val
3283 }, (disp_val != null) ? ((disp_val instanceof DocumentFragment) ? disp_val.cloneNode(true) : disp_val) : ''));
3284
3285 if (typeof(captionClasses) == 'object')
3286 DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[i]));
3287
3288 if (!td.classList.contains('cbi-section-actions'))
3289 headings[i].setAttribute('data-sortable-row', true);
3290 }
3291
3292 trows[n].classList.add('cbi-rowstyle-%d'.format((n++ % 2) ? 2 : 1));
3293 });
3294
3295 for (var i = 0; i < n; i++) {
3296 if (rows[i+1])
3297 this.node.replaceChild(trows[i], rows[i+1]);
3298 else
3299 this.node.appendChild(trows[i]);
3300 }
3301
3302 while (rows[++n])
3303 this.node.removeChild(rows[n]);
3304
3305 if (placeholder && this.node.firstElementChild === this.node.lastElementChild) {
3306 var trow = this.node.appendChild(E(trTag, { 'class': 'tr placeholder' })),
3307 td = trow.appendChild(E(tdTag, { 'class': 'td' }, placeholder));
3308
3309 if (typeof(captionClasses) == 'object')
3310 DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
3311 }
3312
3313 return this.node;
3314 },
3315
3316 render: function() {
3317 return this.node;
3318 },
3319
3320 /** @private */
3321 initFromMarkup: function(node) {
3322 if (!dom.elem(node))
3323 node = document.querySelector(node);
3324
3325 if (!node)
3326 throw 'Invalid table selector';
3327
3328 var options = {},
3329 headrow = node.querySelector('tr, .tr');
3330
3331 if (!headrow)
3332 return;
3333
3334 options.id = node.id;
3335 options.classes = [].slice.call(node.classList).filter(function(c) { return c != 'table' });
3336 options.sortable = [];
3337 options.captionClasses = [];
3338
3339 headrow.querySelectorAll('th, .th').forEach(function(th, i) {
3340 options.sortable[i] = !th.classList.contains('cbi-section-actions');
3341 options.captionClasses[i] = [].slice.call(th.classList).filter(function(c) { return c != 'th' });
3342 });
3343
3344 headrow.addEventListener('click', UI.prototype.createHandlerFn(this, 'handleSort'));
3345
3346 this.id = node.id;
3347 this.node = node;
3348 this.options = options;
3349 },
3350
3351 /** @private */
3352 deriveSortKey: function(value, index) {
3353 var opts = this.options || {},
3354 hint, m;
3355
3356 if (opts.sortable == true || opts.sortable == null)
3357 hint = 'auto';
3358 else if (typeof( opts.sortable) == 'object')
3359 hint = opts.sortable[index];
3360
3361 if (dom.elem(value)) {
3362 if (value.hasAttribute('data-value'))
3363 value = value.getAttribute('data-value');
3364 else
3365 value = (value.innerText || '').trim();
3366 }
3367
3368 switch (hint || 'auto') {
3369 case true:
3370 case 'auto':
3371 m = /^([0-9a-fA-F:.]+)(?:\/([0-9a-fA-F:.]+))?$/.exec(value);
3372
3373 if (m) {
3374 var addr, mask;
3375
3376 addr = validation.parseIPv6(m[1]);
3377 mask = m[2] ? validation.parseIPv6(m[2]) : null;
3378
3379 if (addr && mask != null)
3380 return '%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x'.format(
3381 addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
3382 mask[0], mask[1], mask[2], mask[3], mask[4], mask[5], mask[6], mask[7]
3383 );
3384 else if (addr)
3385 return '%04x%04x%04x%04x%04x%04x%04x%04x%02x'.format(
3386 addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
3387 m[2] ? +m[2] : 128
3388 );
3389
3390 addr = validation.parseIPv4(m[1]);
3391 mask = m[2] ? validation.parseIPv4(m[2]) : null;
3392
3393 if (addr && mask != null)
3394 return '%03d%03d%03d%03d%03d%03d%03d%03d'.format(
3395 addr[0], addr[1], addr[2], addr[3],
3396 mask[0], mask[1], mask[2], mask[3]
3397 );
3398 else if (addr)
3399 return '%03d%03d%03d%03d%02d'.format(
3400 addr[0], addr[1], addr[2], addr[3],
3401 m[2] ? +m[2] : 32
3402 );
3403 }
3404
3405 m = /^(?:(\d+)d )?(\d+)h (\d+)m (\d+)s$/.exec(value);
3406
3407 if (m)
3408 return '%05d%02d%02d%02d'.format(+m[1], +m[2], +m[3], +m[4]);
3409
3410 m = /^(\d+)\b(\D*)$/.exec(value);
3411
3412 if (m)
3413 return '%010d%s'.format(+m[1], m[2]);
3414
3415 return String(value);
3416
3417 case 'ignorecase':
3418 return String(value).toLowerCase();
3419
3420 case 'numeric':
3421 return +value;
3422
3423 default:
3424 return String(value);
3425 }
3426 },
3427
3428 /** @private */
3429 getActiveSortState: function() {
3430 if (this.sortState)
3431 return this.sortState;
3432
3433 if (!this.options.id)
3434 return null;
3435
3436 var page = document.body.getAttribute('data-page'),
3437 key = page + '.' + this.options.id,
3438 state = session.getLocalData('tablesort');
3439
3440 if (L.isObject(state) && Array.isArray(state[key]))
3441 return state[key];
3442
3443 return null;
3444 },
3445
3446 /** @private */
3447 setActiveSortState: function(index, descending) {
3448 this.sortState = [ index, descending ];
3449
3450 if (!this.options.id)
3451 return;
3452
3453 var page = document.body.getAttribute('data-page'),
3454 key = page + '.' + this.options.id,
3455 state = session.getLocalData('tablesort');
3456
3457 if (!L.isObject(state))
3458 state = {};
3459
3460 state[key] = this.sortState;
3461
3462 session.setLocalData('tablesort', state);
3463 },
3464
3465 /** @private */
3466 handleSort: function(ev) {
3467 if (!ev.target.matches('th[data-sortable-row]'))
3468 return;
3469
3470 var index, direction;
3471
3472 this.node.firstElementChild.querySelectorAll('th, .th').forEach(function(th, i) {
3473 if (th === ev.target) {
3474 index = i;
3475 direction = th.getAttribute('data-sort-direction') == 'asc';
3476 }
3477 });
3478
3479 this.setActiveSortState(index, direction);
3480 this.update(this.data, this.placeholder);
3481 }
3482 });
3483
3484 /**
3485 * @class ui
3486 * @memberof LuCI
3487 * @hideconstructor
3488 * @classdesc
3489 *
3490 * Provides high level UI helper functionality.
3491 * To import the class in views, use `'require ui'`, to import it in
3492 * external JavaScript, use `L.require("ui").then(...)`.
3493 */
3494 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
3495 __init__: function() {
3496 modalDiv = document.body.appendChild(
3497 dom.create('div', {
3498 id: 'modal_overlay',
3499 tabindex: -1,
3500 keydown: this.cancelModal
3501 }, [
3502 dom.create('div', {
3503 class: 'modal',
3504 role: 'dialog',
3505 'aria-modal': true
3506 })
3507 ]));
3508
3509 tooltipDiv = document.body.appendChild(
3510 dom.create('div', { class: 'cbi-tooltip' }));
3511
3512 /* setup old aliases */
3513 L.showModal = this.showModal;
3514 L.hideModal = this.hideModal;
3515 L.showTooltip = this.showTooltip;
3516 L.hideTooltip = this.hideTooltip;
3517 L.itemlist = this.itemlist;
3518
3519 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
3520 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
3521 document.addEventListener('focus', this.showTooltip.bind(this), true);
3522 document.addEventListener('blur', this.hideTooltip.bind(this), true);
3523
3524 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
3525 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
3526 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
3527 },
3528
3529 /**
3530 * Display a modal overlay dialog with the specified contents.
3531 *
3532 * The modal overlay dialog covers the current view preventing interaction
3533 * with the underlying view contents. Only one modal dialog instance can
3534 * be opened. Invoking showModal() while a modal dialog is already open will
3535 * replace the open dialog with a new one having the specified contents.
3536 *
3537 * Additional CSS class names may be passed to influence the appearance of
3538 * the dialog. Valid values for the classes depend on the underlying theme.
3539 *
3540 * @see LuCI.dom.content
3541 *
3542 * @param {string} [title]
3543 * The title of the dialog. If `null`, no title element will be rendered.
3544 *
3545 * @param {*} children
3546 * The contents to add to the modal dialog. This should be a DOM node or
3547 * a document fragment in most cases. The value is passed as-is to the
3548 * `dom.content()` function - refer to its documentation for applicable
3549 * values.
3550 *
3551 * @param {...string} [classes]
3552 * A number of extra CSS class names which are set on the modal dialog
3553 * element.
3554 *
3555 * @returns {Node}
3556 * Returns a DOM Node representing the modal dialog element.
3557 */
3558 showModal: function(title, children /* , ... */) {
3559 var dlg = modalDiv.firstElementChild;
3560
3561 dlg.setAttribute('class', 'modal');
3562
3563 for (var i = 2; i < arguments.length; i++)
3564 dlg.classList.add(arguments[i]);
3565
3566 dom.content(dlg, dom.create('h4', {}, title));
3567 dom.append(dlg, children);
3568
3569 document.body.classList.add('modal-overlay-active');
3570 modalDiv.scrollTop = 0;
3571 modalDiv.focus();
3572
3573 return dlg;
3574 },
3575
3576 /**
3577 * Close the open modal overlay dialog.
3578 *
3579 * This function will close an open modal dialog and restore the normal view
3580 * behaviour. It has no effect if no modal dialog is currently open.
3581 *
3582 * Note that this function is stand-alone, it does not rely on `this` and
3583 * will not invoke other class functions so it suitable to be used as event
3584 * handler as-is without the need to bind it first.
3585 */
3586 hideModal: function() {
3587 document.body.classList.remove('modal-overlay-active');
3588 modalDiv.blur();
3589 },
3590
3591 /** @private */
3592 cancelModal: function(ev) {
3593 if (ev.key == 'Escape') {
3594 var btn = modalDiv.querySelector('.right > button, .right > .btn');
3595
3596 if (btn)
3597 btn.click();
3598 }
3599 },
3600
3601 /** @private */
3602 showTooltip: function(ev) {
3603 var target = findParent(ev.target, '[data-tooltip]');
3604
3605 if (!target)
3606 return;
3607
3608 if (tooltipTimeout !== null) {
3609 window.clearTimeout(tooltipTimeout);
3610 tooltipTimeout = null;
3611 }
3612
3613 var rect = target.getBoundingClientRect(),
3614 x = rect.left + window.pageXOffset,
3615 y = rect.top + rect.height + window.pageYOffset,
3616 above = false;
3617
3618 tooltipDiv.className = 'cbi-tooltip';
3619 tooltipDiv.innerHTML = '▲ ';
3620 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3621
3622 if (target.hasAttribute('data-tooltip-style'))
3623 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3624
3625 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset))
3626 above = true;
3627
3628 var dropdown = target.querySelector('ul.dropdown[style]:first-child');
3629
3630 if (dropdown && dropdown.style.top)
3631 above = true;
3632
3633 if (above) {
3634 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3635 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
3636 }
3637
3638 tooltipDiv.style.top = y + 'px';
3639 tooltipDiv.style.left = x + 'px';
3640 tooltipDiv.style.opacity = 1;
3641
3642 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3643 bubbles: true,
3644 detail: { target: target }
3645 }));
3646 },
3647
3648 /** @private */
3649 hideTooltip: function(ev) {
3650 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3651 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3652 return;
3653
3654 if (tooltipTimeout !== null) {
3655 window.clearTimeout(tooltipTimeout);
3656 tooltipTimeout = null;
3657 }
3658
3659 tooltipDiv.style.opacity = 0;
3660 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3661
3662 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3663 },
3664
3665 /**
3666 * Add a notification banner at the top of the current view.
3667 *
3668 * A notification banner is an alert message usually displayed at the
3669 * top of the current view, spanning the entire available width.
3670 * Notification banners will stay in place until dismissed by the user.
3671 * Multiple banners may be shown at the same time.
3672 *
3673 * Additional CSS class names may be passed to influence the appearance of
3674 * the banner. Valid values for the classes depend on the underlying theme.
3675 *
3676 * @see LuCI.dom.content
3677 *
3678 * @param {string} [title]
3679 * The title of the notification banner. If `null`, no title element
3680 * will be rendered.
3681 *
3682 * @param {*} children
3683 * The contents to add to the notification banner. This should be a DOM
3684 * node or a document fragment in most cases. The value is passed as-is
3685 * to the `dom.content()` function - refer to its documentation for
3686 * applicable values.
3687 *
3688 * @param {...string} [classes]
3689 * A number of extra CSS class names which are set on the notification
3690 * banner element.
3691 *
3692 * @returns {Node}
3693 * Returns a DOM Node representing the notification banner element.
3694 */
3695 addNotification: function(title, children /*, ... */) {
3696 var mc = document.querySelector('#maincontent') || document.body;
3697 var msg = E('div', {
3698 'class': 'alert-message fade-in',
3699 'style': 'display:flex',
3700 'transitionend': function(ev) {
3701 var node = ev.currentTarget;
3702 if (node.parentNode && node.classList.contains('fade-out'))
3703 node.parentNode.removeChild(node);
3704 }
3705 }, [
3706 E('div', { 'style': 'flex:10' }),
3707 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3708 E('button', {
3709 'class': 'btn',
3710 'style': 'margin-left:auto; margin-top:auto',
3711 'click': function(ev) {
3712 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3713 },
3714
3715 }, [ _('Dismiss') ])
3716 ])
3717 ]);
3718
3719 if (title != null)
3720 dom.append(msg.firstElementChild, E('h4', {}, title));
3721
3722 dom.append(msg.firstElementChild, children);
3723
3724 for (var i = 2; i < arguments.length; i++)
3725 msg.classList.add(arguments[i]);
3726
3727 mc.insertBefore(msg, mc.firstElementChild);
3728
3729 return msg;
3730 },
3731
3732 /**
3733 * Display or update a header area indicator.
3734 *
3735 * An indicator is a small label displayed in the header area of the screen
3736 * providing few amounts of status information such as item counts or state
3737 * toggle indicators.
3738 *
3739 * Multiple indicators may be shown at the same time and indicator labels
3740 * may be made clickable to display extended information or to initiate
3741 * further actions.
3742 *
3743 * Indicators can either use a default `active` or a less accented `inactive`
3744 * style which is useful for indicators representing state toggles.
3745 *
3746 * @param {string} id
3747 * The ID of the indicator. If an indicator with the given ID already exists,
3748 * it is updated with the given label and style.
3749 *
3750 * @param {string} label
3751 * The text to display in the indicator label.
3752 *
3753 * @param {function} [handler]
3754 * A handler function to invoke when the indicator label is clicked/touched
3755 * by the user. If omitted, the indicator is not clickable/touchable.
3756 *
3757 * Note that this parameter only applies to new indicators, when updating
3758 * existing labels it is ignored.
3759 *
3760 * @param {"active"|"inactive"} [style=active]
3761 * The indicator style to use. May be either `active` or `inactive`.
3762 *
3763 * @returns {boolean}
3764 * Returns `true` when the indicator has been updated or `false` when no
3765 * changes were made.
3766 */
3767 showIndicator: function(id, label, handler, style) {
3768 if (indicatorDiv == null) {
3769 indicatorDiv = document.body.querySelector('#indicators');
3770
3771 if (indicatorDiv == null)
3772 return false;
3773 }
3774
3775 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3776 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id));
3777
3778 if (indicatorElem == null) {
3779 var beforeElem = null;
3780
3781 for (beforeElem = indicatorDiv.firstElementChild;
3782 beforeElem != null;
3783 beforeElem = beforeElem.nextElementSibling)
3784 if (beforeElem.getAttribute('data-indicator') > id)
3785 break;
3786
3787 indicatorElem = indicatorDiv.insertBefore(E('span', {
3788 'data-indicator': id,
3789 'data-clickable': handlerFn ? true : null,
3790 'click': handlerFn
3791 }, ['']), beforeElem);
3792 }
3793
3794 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3795 return false;
3796
3797 indicatorElem.firstChild.data = label;
3798 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3799 return true;
3800 },
3801
3802 /**
3803 * Remove a header area indicator.
3804 *
3805 * This function removes the given indicator label from the header indicator
3806 * area. When the given indicator is not found, this function does nothing.
3807 *
3808 * @param {string} id
3809 * The ID of the indicator to remove.
3810 *
3811 * @returns {boolean}
3812 * Returns `true` when the indicator has been removed or `false` when the
3813 * requested indicator was not found.
3814 */
3815 hideIndicator: function(id) {
3816 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3817
3818 if (indicatorElem == null)
3819 return false;
3820
3821 indicatorDiv.removeChild(indicatorElem);
3822 return true;
3823 },
3824
3825 /**
3826 * Formats a series of label/value pairs into list-like markup.
3827 *
3828 * This function transforms a flat array of alternating label and value
3829 * elements into a list-like markup, using the values in `separators` as
3830 * separators and appends the resulting nodes to the given parent DOM node.
3831 *
3832 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3833 * `<strong>` element and the value corresponding to the label are
3834 * subsequently wrapped into a `<span class="nowrap">` element.
3835 *
3836 * The resulting `<span>` element tuples are joined by the given separators
3837 * to form the final markup which is appended to the given parent DOM node.
3838 *
3839 * @param {Node} node
3840 * The parent DOM node to append the markup to. Any previous child elements
3841 * will be removed.
3842 *
3843 * @param {Array<*>} items
3844 * An alternating array of labels and values. The label values will be
3845 * converted to plain strings, the values are used as-is and may be of
3846 * any type accepted by `LuCI.dom.content()`.
3847 *
3848 * @param {*|Array<*>} [separators=[E('br')]]
3849 * A single value or an array of separator values to separate each
3850 * label/value pair with. The function will cycle through the separators
3851 * when joining the pairs. If omitted, the default separator is a sole HTML
3852 * `<br>` element. Separator values are used as-is and may be of any type
3853 * accepted by `LuCI.dom.content()`.
3854 *
3855 * @returns {Node}
3856 * Returns the parent DOM node the formatted markup has been added to.
3857 */
3858 itemlist: function(node, items, separators) {
3859 var children = [];
3860
3861 if (!Array.isArray(separators))
3862 separators = [ separators || E('br') ];
3863
3864 for (var i = 0; i < items.length; i += 2) {
3865 if (items[i+1] !== null && items[i+1] !== undefined) {
3866 var sep = separators[(i/2) % separators.length],
3867 cld = [];
3868
3869 children.push(E('span', { class: 'nowrap' }, [
3870 items[i] ? E('strong', items[i] + ': ') : '',
3871 items[i+1]
3872 ]));
3873
3874 if ((i+2) < items.length)
3875 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3876 }
3877 }
3878
3879 dom.content(node, children);
3880
3881 return node;
3882 },
3883
3884 /**
3885 * @class
3886 * @memberof LuCI.ui
3887 * @hideconstructor
3888 * @classdesc
3889 *
3890 * The `tabs` class handles tab menu groups used throughout the view area.
3891 * It takes care of setting up tab groups, tracking their state and handling
3892 * related events.
3893 *
3894 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3895 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3896 * external JavaScript, use `L.require("ui").then(...)` and access the
3897 * `tabs` property of the class instance value.
3898 */
3899 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3900 /** @private */
3901 init: function() {
3902 var groups = [], prevGroup = null, currGroup = null;
3903
3904 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3905 var parent = tab.parentNode;
3906
3907 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3908 return;
3909
3910 if (!parent.hasAttribute('data-tab-group'))
3911 parent.setAttribute('data-tab-group', groups.length);
3912
3913 currGroup = +parent.getAttribute('data-tab-group');
3914
3915 if (currGroup !== prevGroup) {
3916 prevGroup = currGroup;
3917
3918 if (!groups[currGroup])
3919 groups[currGroup] = [];
3920 }
3921
3922 groups[currGroup].push(tab);
3923 });
3924
3925 for (var i = 0; i < groups.length; i++)
3926 this.initTabGroup(groups[i]);
3927
3928 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3929
3930 this.updateTabs();
3931 },
3932
3933 /**
3934 * Initializes a new tab group from the given tab pane collection.
3935 *
3936 * This function cycles through the given tab pane DOM nodes, extracts
3937 * their tab IDs, titles and active states, renders a corresponding
3938 * tab menu and prepends it to the tab panes common parent DOM node.
3939 *
3940 * The tab menu labels will be set to the value of the `data-tab-title`
3941 * attribute of each corresponding pane. The last pane with the
3942 * `data-tab-active` attribute set to `true` will be selected by default.
3943 *
3944 * If no pane is marked as active, the first one will be preselected.
3945 *
3946 * @instance
3947 * @memberof LuCI.ui.tabs
3948 * @param {Array<Node>|NodeList} panes
3949 * A collection of tab panes to build a tab group menu for. May be a
3950 * plain array of DOM nodes or a NodeList collection, such as the result
3951 * of a `querySelectorAll()` call or the `.childNodes` property of a
3952 * DOM node.
3953 */
3954 initTabGroup: function(panes) {
3955 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3956 return;
3957
3958 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3959 group = panes[0].parentNode,
3960 groupId = +group.getAttribute('data-tab-group'),
3961 selected = null;
3962
3963 if (group.getAttribute('data-initialized') === 'true')
3964 return;
3965
3966 for (var i = 0, pane; pane = panes[i]; i++) {
3967 var name = pane.getAttribute('data-tab'),
3968 title = pane.getAttribute('data-tab-title'),
3969 active = pane.getAttribute('data-tab-active') === 'true';
3970
3971 menu.appendChild(E('li', {
3972 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3973 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3974 'data-tab': name
3975 }, E('a', {
3976 'href': '#',
3977 'click': this.switchTab.bind(this)
3978 }, title)));
3979
3980 if (active)
3981 selected = i;
3982 }
3983
3984 group.parentNode.insertBefore(menu, group);
3985 group.setAttribute('data-initialized', true);
3986
3987 if (selected === null) {
3988 selected = this.getActiveTabId(panes[0]);
3989
3990 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3991 for (var i = 0; i < panes.length; i++) {
3992 if (!this.isEmptyPane(panes[i])) {
3993 selected = i;
3994 break;
3995 }
3996 }
3997 }
3998
3999 menu.childNodes[selected].classList.add('cbi-tab');
4000 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
4001 panes[selected].setAttribute('data-tab-active', 'true');
4002
4003 this.setActiveTabId(panes[selected], selected);
4004 }
4005
4006 requestAnimationFrame(L.bind(function(pane) {
4007 pane.dispatchEvent(new CustomEvent('cbi-tab-active', {
4008 detail: { tab: pane.getAttribute('data-tab') }
4009 }));
4010 }, this, panes[selected]));
4011
4012 this.updateTabs(group);
4013 },
4014
4015 /**
4016 * Checks whether the given tab pane node is empty.
4017 *
4018 * @instance
4019 * @memberof LuCI.ui.tabs
4020 * @param {Node} pane
4021 * The tab pane to check.
4022 *
4023 * @returns {boolean}
4024 * Returns `true` if the pane is empty, else `false`.
4025 */
4026 isEmptyPane: function(pane) {
4027 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
4028 },
4029
4030 /** @private */
4031 getPathForPane: function(pane) {
4032 var path = [], node = null;
4033
4034 for (node = pane ? pane.parentNode : null;
4035 node != null && node.hasAttribute != null;
4036 node = node.parentNode)
4037 {
4038 if (node.hasAttribute('data-tab'))
4039 path.unshift(node.getAttribute('data-tab'));
4040 else if (node.hasAttribute('data-section-id'))
4041 path.unshift(node.getAttribute('data-section-id'));
4042 }
4043
4044 return path.join('/');
4045 },
4046
4047 /** @private */
4048 getActiveTabState: function() {
4049 var page = document.body.getAttribute('data-page'),
4050 state = session.getLocalData('tab');
4051
4052 if (L.isObject(state) && state.page === page && L.isObject(state.paths))
4053 return state;
4054
4055 session.setLocalData('tab', null);
4056
4057 return { page: page, paths: {} };
4058 },
4059
4060 /** @private */
4061 getActiveTabId: function(pane) {
4062 var path = this.getPathForPane(pane);
4063 return +this.getActiveTabState().paths[path] || 0;
4064 },
4065
4066 /** @private */
4067 setActiveTabId: function(pane, tabIndex) {
4068 var path = this.getPathForPane(pane),
4069 state = this.getActiveTabState();
4070
4071 state.paths[path] = tabIndex;
4072
4073 return session.setLocalData('tab', state);
4074 },
4075
4076 /** @private */
4077 updateTabs: function(ev, root) {
4078 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
4079 var menu = pane.parentNode.previousElementSibling,
4080 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
4081 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
4082
4083 if (!menu || !tab)
4084 return;
4085
4086 if (this.isEmptyPane(pane)) {
4087 tab.style.display = 'none';
4088 tab.classList.remove('flash');
4089 }
4090 else if (tab.style.display === 'none') {
4091 tab.style.display = '';
4092 requestAnimationFrame(function() { tab.classList.add('flash') });
4093 }
4094
4095 if (n_errors) {
4096 tab.setAttribute('data-errors', n_errors);
4097 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
4098 tab.setAttribute('data-tooltip-style', 'error');
4099 }
4100 else {
4101 tab.removeAttribute('data-errors');
4102 tab.removeAttribute('data-tooltip');
4103 }
4104 }, this));
4105 },
4106
4107 /** @private */
4108 switchTab: function(ev) {
4109 var tab = ev.target.parentNode,
4110 name = tab.getAttribute('data-tab'),
4111 menu = tab.parentNode,
4112 group = menu.nextElementSibling,
4113 groupId = +group.getAttribute('data-tab-group'),
4114 index = 0;
4115
4116 ev.preventDefault();
4117
4118 if (!tab.classList.contains('cbi-tab-disabled'))
4119 return;
4120
4121 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
4122 tab.classList.remove('cbi-tab');
4123 tab.classList.remove('cbi-tab-disabled');
4124 tab.classList.add(
4125 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
4126 });
4127
4128 group.childNodes.forEach(function(pane) {
4129 if (dom.matches(pane, '[data-tab]')) {
4130 if (pane.getAttribute('data-tab') === name) {
4131 pane.setAttribute('data-tab-active', 'true');
4132 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
4133 UI.prototype.tabs.setActiveTabId(pane, index);
4134 }
4135 else {
4136 pane.setAttribute('data-tab-active', 'false');
4137 }
4138
4139 index++;
4140 }
4141 });
4142 }
4143 }),
4144
4145 /**
4146 * @typedef {Object} FileUploadReply
4147 * @memberof LuCI.ui
4148
4149 * @property {string} name - Name of the uploaded file without directory components
4150 * @property {number} size - Size of the uploaded file in bytes
4151 * @property {string} checksum - The MD5 checksum of the received file data
4152 * @property {string} sha256sum - The SHA256 checksum of the received file data
4153 */
4154
4155 /**
4156 * Display a modal file upload prompt.
4157 *
4158 * This function opens a modal dialog prompting the user to select and
4159 * upload a file to a predefined remote destination path.
4160 *
4161 * @param {string} path
4162 * The remote file path to upload the local file to.
4163 *
4164 * @param {Node} [progressStatusNode]
4165 * An optional DOM text node whose content text is set to the progress
4166 * percentage value during file upload.
4167 *
4168 * @returns {Promise<LuCI.ui.FileUploadReply>}
4169 * Returns a promise resolving to a file upload status object on success
4170 * or rejecting with an error in case the upload failed or has been
4171 * cancelled by the user.
4172 */
4173 uploadFile: function(path, progressStatusNode) {
4174 return new Promise(function(resolveFn, rejectFn) {
4175 UI.prototype.showModal(_('Uploading file…'), [
4176 E('p', _('Please select the file to upload.')),
4177 E('div', { 'style': 'display:flex' }, [
4178 E('div', { 'class': 'left', 'style': 'flex:1' }, [
4179 E('input', {
4180 type: 'file',
4181 style: 'display:none',
4182 change: function(ev) {
4183 var modal = dom.parent(ev.target, '.modal'),
4184 body = modal.querySelector('p'),
4185 upload = modal.querySelector('.cbi-button-action.important'),
4186 file = ev.currentTarget.files[0];
4187
4188 if (file == null)
4189 return;
4190
4191 dom.content(body, [
4192 E('ul', {}, [
4193 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
4194 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
4195 ])
4196 ]);
4197
4198 upload.disabled = false;
4199 upload.focus();
4200 }
4201 }),
4202 E('button', {
4203 'class': 'btn',
4204 'click': function(ev) {
4205 ev.target.previousElementSibling.click();
4206 }
4207 }, [ _('Browse…') ])
4208 ]),
4209 E('div', { 'class': 'right', 'style': 'flex:1' }, [
4210 E('button', {
4211 'class': 'btn',
4212 'click': function() {
4213 UI.prototype.hideModal();
4214 rejectFn(new Error(_('Upload has been cancelled')));
4215 }
4216 }, [ _('Cancel') ]),
4217 ' ',
4218 E('button', {
4219 'class': 'btn cbi-button-action important',
4220 'disabled': true,
4221 'click': function(ev) {
4222 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
4223
4224 if (!input.files[0])
4225 return;
4226
4227 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
4228
4229 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
4230
4231 var data = new FormData();
4232
4233 data.append('sessionid', rpc.getSessionID());
4234 data.append('filename', path);
4235 data.append('filedata', input.files[0]);
4236
4237 var filename = input.files[0].name;
4238
4239 request.post(L.env.cgi_base + '/cgi-upload', data, {
4240 timeout: 0,
4241 progress: function(pev) {
4242 var percent = (pev.loaded / pev.total) * 100;
4243
4244 if (progressStatusNode)
4245 progressStatusNode.data = '%.2f%%'.format(percent);
4246
4247 progress.setAttribute('title', '%.2f%%'.format(percent));
4248 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
4249 }
4250 }).then(function(res) {
4251 var reply = res.json();
4252
4253 UI.prototype.hideModal();
4254
4255 if (L.isObject(reply) && reply.failure) {
4256 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
4257 rejectFn(new Error(reply.failure));
4258 }
4259 else {
4260 reply.name = filename;
4261 resolveFn(reply);
4262 }
4263 }, function(err) {
4264 UI.prototype.hideModal();
4265 rejectFn(err);
4266 });
4267 }
4268 }, [ _('Upload') ])
4269 ])
4270 ])
4271 ]);
4272 });
4273 },
4274
4275 /**
4276 * Perform a device connectivity test.
4277 *
4278 * Attempt to fetch a well known resource from the remote device via HTTP
4279 * in order to test connectivity. This function is mainly useful to wait
4280 * for the router to come back online after a reboot or reconfiguration.
4281 *
4282 * @param {string} [proto=http]
4283 * The protocol to use for fetching the resource. May be either `http`
4284 * (the default) or `https`.
4285 *
4286 * @param {string} [ipaddr=window.location.host]
4287 * Override the host address to probe. By default the current host as seen
4288 * in the address bar is probed.
4289 *
4290 * @returns {Promise<Event>}
4291 * Returns a promise resolving to a `load` event in case the device is
4292 * reachable or rejecting with an `error` event in case it is not reachable
4293 * or rejecting with `null` when the connectivity check timed out.
4294 */
4295 pingDevice: function(proto, ipaddr) {
4296 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
4297
4298 return new Promise(function(resolveFn, rejectFn) {
4299 var img = new Image();
4300
4301 img.onload = resolveFn;
4302 img.onerror = rejectFn;
4303
4304 window.setTimeout(rejectFn, 1000);
4305
4306 img.src = target;
4307 });
4308 },
4309
4310 /**
4311 * Wait for device to come back online and reconnect to it.
4312 *
4313 * Poll each given hostname or IP address and navigate to it as soon as
4314 * one of the addresses becomes reachable.
4315 *
4316 * @param {...string} [hosts=[window.location.host]]
4317 * The list of IP addresses and host names to check for reachability.
4318 * If omitted, the current value of `window.location.host` is used by
4319 * default.
4320 */
4321 awaitReconnect: function(/* ... */) {
4322 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
4323
4324 window.setTimeout(L.bind(function() {
4325 poll.add(L.bind(function() {
4326 var tasks = [], reachable = false;
4327
4328 for (var i = 0; i < 2; i++)
4329 for (var j = 0; j < ipaddrs.length; j++)
4330 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
4331 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
4332
4333 return Promise.all(tasks).then(function() {
4334 if (reachable) {
4335 poll.stop();
4336 window.location = reachable;
4337 }
4338 });
4339 }, this));
4340 }, this), 5000);
4341 },
4342
4343 /**
4344 * @class
4345 * @memberof LuCI.ui
4346 * @hideconstructor
4347 * @classdesc
4348 *
4349 * The `changes` class encapsulates logic for visualizing, applying,
4350 * confirming and reverting staged UCI changesets.
4351 *
4352 * This class is automatically instantiated as part of `LuCI.ui`. To use it
4353 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
4354 * external JavaScript, use `L.require("ui").then(...)` and access the
4355 * `changes` property of the class instance value.
4356 */
4357 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
4358 init: function() {
4359 if (!L.env.sessionid)
4360 return;
4361
4362 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
4363 },
4364
4365 /**
4366 * Set the change count indicator.
4367 *
4368 * This function updates or hides the UCI change count indicator,
4369 * depending on the passed change count. When the count is greater
4370 * than 0, the change indicator is displayed or updated, otherwise it
4371 * is removed.
4372 *
4373 * @instance
4374 * @memberof LuCI.ui.changes
4375 * @param {number} n
4376 * The number of changes to indicate.
4377 */
4378 setIndicator: function(n) {
4379 if (n > 0) {
4380 UI.prototype.showIndicator('uci-changes',
4381 '%s: %d'.format(_('Unsaved Changes'), n),
4382 L.bind(this.displayChanges, this));
4383 }
4384 else {
4385 UI.prototype.hideIndicator('uci-changes');
4386 }
4387 },
4388
4389 /**
4390 * Update the change count indicator.
4391 *
4392 * This function updates the UCI change count indicator from the given
4393 * UCI changeset structure.
4394 *
4395 * @instance
4396 * @memberof LuCI.ui.changes
4397 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
4398 * The UCI changeset to count.
4399 */
4400 renderChangeIndicator: function(changes) {
4401 var n_changes = 0;
4402
4403 for (var config in changes)
4404 if (changes.hasOwnProperty(config))
4405 n_changes += changes[config].length;
4406
4407 this.changes = changes;
4408 this.setIndicator(n_changes);
4409 },
4410
4411 /** @private */
4412 changeTemplates: {
4413 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
4414 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
4415 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
4416 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
4417 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
4418 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
4419 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
4420 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
4421 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
4422 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
4423 },
4424
4425 /**
4426 * Display the current changelog.
4427 *
4428 * Open a modal dialog visualizing the currently staged UCI changes
4429 * and offer options to revert or apply the shown changes.
4430 *
4431 * @instance
4432 * @memberof LuCI.ui.changes
4433 */
4434 displayChanges: function() {
4435 var list = E('div', { 'class': 'uci-change-list' }),
4436 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
4437 E('div', { 'class': 'cbi-section' }, [
4438 E('strong', _('Legend:')),
4439 E('div', { 'class': 'uci-change-legend' }, [
4440 E('div', { 'class': 'uci-change-legend-label' }, [
4441 E('ins', '&#160;'), ' ', _('Section added') ]),
4442 E('div', { 'class': 'uci-change-legend-label' }, [
4443 E('del', '&#160;'), ' ', _('Section removed') ]),
4444 E('div', { 'class': 'uci-change-legend-label' }, [
4445 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
4446 E('div', { 'class': 'uci-change-legend-label' }, [
4447 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
4448 E('br'), list,
4449 E('div', { 'class': 'right' }, [
4450 E('button', {
4451 'class': 'btn',
4452 'click': UI.prototype.hideModal
4453 }, [ _('Close') ]), ' ',
4454 new UIComboButton('0', {
4455 0: [ _('Save & Apply') ],
4456 1: [ _('Apply unchecked') ]
4457 }, {
4458 classes: {
4459 0: 'btn cbi-button cbi-button-positive important',
4460 1: 'btn cbi-button cbi-button-negative important'
4461 },
4462 click: L.bind(function(ev, mode) { this.apply(mode == '0') }, this)
4463 }).render(), ' ',
4464 E('button', {
4465 'class': 'cbi-button cbi-button-reset',
4466 'click': L.bind(this.revert, this)
4467 }, [ _('Revert') ])])])
4468 ]);
4469
4470 for (var config in this.changes) {
4471 if (!this.changes.hasOwnProperty(config))
4472 continue;
4473
4474 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
4475
4476 for (var i = 0, added = null; i < this.changes[config].length; i++) {
4477 var chg = this.changes[config][i],
4478 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
4479
4480 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
4481 switch (+m1) {
4482 case 0:
4483 return config;
4484
4485 case 2:
4486 if (added != null && chg[1] == added[0])
4487 return '@' + added[1] + '[-1]';
4488 else
4489 return chg[1];
4490
4491 case 4:
4492 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
4493
4494 default:
4495 return chg[m1-1];
4496 }
4497 })));
4498
4499 if (chg[0] == 'add')
4500 added = [ chg[1], chg[2] ];
4501 }
4502 }
4503
4504 list.appendChild(E('br'));
4505 dlg.classList.add('uci-dialog');
4506 },
4507
4508 /** @private */
4509 displayStatus: function(type, content) {
4510 if (type) {
4511 var message = UI.prototype.showModal('', '');
4512
4513 message.classList.add('alert-message');
4514 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4515
4516 if (content)
4517 dom.content(message, content);
4518
4519 if (!this.was_polling) {
4520 this.was_polling = request.poll.active();
4521 request.poll.stop();
4522 }
4523 }
4524 else {
4525 UI.prototype.hideModal();
4526
4527 if (this.was_polling)
4528 request.poll.start();
4529 }
4530 },
4531
4532 /** @private */
4533 checkConnectivityAffected: function() {
4534 return L.resolveDefault(fs.exec_direct('/usr/libexec/luci-peeraddr', null, 'json')).then(L.bind(function(info) {
4535 if (L.isObject(info) && Array.isArray(info.inbound_interfaces)) {
4536 for (var i = 0; i < info.inbound_interfaces.length; i++) {
4537 var iif = info.inbound_interfaces[i];
4538
4539 for (var j = 0; this.changes && this.changes.network && j < this.changes.network.length; j++) {
4540 var chg = this.changes.network[j];
4541
4542 if (chg[0] == 'set' && chg[1] == iif &&
4543 ((chg[2] == 'disabled' && chg[3] == '1') || chg[2] == 'proto' || chg[2] == 'ipaddr' || chg[2] == 'netmask'))
4544 return iif;
4545 }
4546 }
4547 }
4548
4549 return null;
4550 }, this));
4551 },
4552
4553 /** @private */
4554 rollback: function(checked) {
4555 if (checked) {
4556 this.displayStatus('warning spinning',
4557 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4558 .format(L.env.apply_rollback)));
4559
4560 var call = function(r, data, duration) {
4561 if (r.status === 204) {
4562 UI.prototype.changes.displayStatus('warning', [
4563 E('h4', _('Configuration changes have been rolled back!')),
4564 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)),
4565 E('div', { 'class': 'right' }, [
4566 E('button', {
4567 'class': 'btn',
4568 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4569 }, [ _('Dismiss') ]), ' ',
4570 E('button', {
4571 'class': 'btn cbi-button-action important',
4572 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4573 }, [ _('Revert changes') ]), ' ',
4574 E('button', {
4575 'class': 'btn cbi-button-negative important',
4576 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4577 }, [ _('Apply unchecked') ])
4578 ])
4579 ]);
4580
4581 return;
4582 }
4583
4584 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4585 window.setTimeout(function() {
4586 request.request(L.url('admin/uci/confirm'), {
4587 method: 'post',
4588 timeout: L.env.apply_timeout * 1000,
4589 query: { sid: L.env.sessionid, token: L.env.token }
4590 }).then(call, call.bind(null, { status: 0 }, null, 0));
4591 }, delay);
4592 };
4593
4594 call({ status: 0 });
4595 }
4596 else {
4597 this.displayStatus('warning', [
4598 E('h4', _('Device unreachable!')),
4599 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.'))
4600 ]);
4601 }
4602 },
4603
4604 /** @private */
4605 confirm: function(checked, deadline, override_token) {
4606 var tt;
4607 var ts = Date.now();
4608
4609 this.displayStatus('notice');
4610
4611 if (override_token)
4612 this.confirm_auth = { token: override_token };
4613
4614 var call = function(r, data, duration) {
4615 if (Date.now() >= deadline) {
4616 window.clearTimeout(tt);
4617 UI.prototype.changes.rollback(checked);
4618 return;
4619 }
4620 else if (r && (r.status === 200 || r.status === 204)) {
4621 document.dispatchEvent(new CustomEvent('uci-applied'));
4622
4623 UI.prototype.changes.setIndicator(0);
4624 UI.prototype.changes.displayStatus('notice',
4625 E('p', _('Configuration changes applied.')));
4626
4627 window.clearTimeout(tt);
4628 window.setTimeout(function() {
4629 //UI.prototype.changes.displayStatus(false);
4630 window.location = window.location.href.split('#')[0];
4631 }, L.env.apply_display * 1000);
4632
4633 return;
4634 }
4635
4636 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4637 window.setTimeout(function() {
4638 request.request(L.url('admin/uci/confirm'), {
4639 method: 'post',
4640 timeout: L.env.apply_timeout * 1000,
4641 query: UI.prototype.changes.confirm_auth
4642 }).then(call, call);
4643 }, delay);
4644 };
4645
4646 var tick = function() {
4647 var now = Date.now();
4648
4649 UI.prototype.changes.displayStatus('notice spinning',
4650 E('p', _('Applying configuration changes… %ds')
4651 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4652
4653 if (now >= deadline)
4654 return;
4655
4656 tt = window.setTimeout(tick, 1000 - (now - ts));
4657 ts = now;
4658 };
4659
4660 tick();
4661
4662 /* wait a few seconds for the settings to become effective */
4663 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4664 },
4665
4666 /**
4667 * Apply the staged configuration changes.
4668 *
4669 * Start applying staged configuration changes and open a modal dialog
4670 * with a progress indication to prevent interaction with the view
4671 * during the apply process. The modal dialog will be automatically
4672 * closed and the current view reloaded once the apply process is
4673 * complete.
4674 *
4675 * @instance
4676 * @memberof LuCI.ui.changes
4677 * @param {boolean} [checked=false]
4678 * Whether to perform a checked (`true`) configuration apply or an
4679 * unchecked (`false`) one.
4680
4681 * In case of a checked apply, the configuration changes must be
4682 * confirmed within a specific time interval, otherwise the device
4683 * will begin to roll back the changes in order to restore the previous
4684 * settings.
4685 */
4686 apply: function(checked) {
4687 this.displayStatus('notice spinning',
4688 E('p', _('Starting configuration apply…')));
4689
4690 (new Promise(function(resolveFn, rejectFn) {
4691 if (!checked)
4692 return resolveFn(false);
4693
4694 UI.prototype.changes.checkConnectivityAffected().then(function(affected) {
4695 if (!affected)
4696 return resolveFn(true);
4697
4698 UI.prototype.changes.displayStatus('warning', [
4699 E('h4', _('Connectivity change')),
4700 E('p', _('The network access to this device could be interrupted by changing settings of the "%h" interface.').format(affected)),
4701 E('p', _('If the IP address used to access LuCI changes, a <strong>manual reconnect to the new IP</strong> is required within %d seconds to confirm the settings, otherwise modifications will be reverted.').format(L.env.apply_rollback)),
4702 E('div', { 'class': 'right' }, [
4703 E('button', {
4704 'class': 'btn',
4705 'click': rejectFn,
4706 }, [ _('Cancel') ]), ' ',
4707 E('button', {
4708 'class': 'btn cbi-button-action important',
4709 'click': resolveFn.bind(null, true)
4710 }, [ _('Apply with revert after connectivity loss') ]), ' ',
4711 E('button', {
4712 'class': 'btn cbi-button-negative important',
4713 'click': resolveFn.bind(null, false)
4714 }, [ _('Apply and keep settings') ])
4715 ])
4716 ]);
4717 });
4718 })).then(function(checked) {
4719 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4720 method: 'post',
4721 query: { sid: L.env.sessionid, token: L.env.token }
4722 }).then(function(r) {
4723 if (r.status === (checked ? 200 : 204)) {
4724 var tok = null; try { tok = r.json(); } catch(e) {}
4725 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4726 UI.prototype.changes.confirm_auth = tok;
4727
4728 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4729 }
4730 else if (checked && r.status === 204) {
4731 UI.prototype.changes.displayStatus('notice',
4732 E('p', _('There are no changes to apply')));
4733
4734 window.setTimeout(function() {
4735 UI.prototype.changes.displayStatus(false);
4736 }, L.env.apply_display * 1000);
4737 }
4738 else {
4739 UI.prototype.changes.displayStatus('warning',
4740 E('p', _('Apply request failed with status <code>%h</code>')
4741 .format(r.responseText || r.statusText || r.status)));
4742
4743 window.setTimeout(function() {
4744 UI.prototype.changes.displayStatus(false);
4745 }, L.env.apply_display * 1000);
4746 }
4747 });
4748 }, this.displayStatus.bind(this, false));
4749 },
4750
4751 /**
4752 * Revert the staged configuration changes.
4753 *
4754 * Start reverting staged configuration changes and open a modal dialog
4755 * with a progress indication to prevent interaction with the view
4756 * during the revert process. The modal dialog will be automatically
4757 * closed and the current view reloaded once the revert process is
4758 * complete.
4759 *
4760 * @instance
4761 * @memberof LuCI.ui.changes
4762 */
4763 revert: function() {
4764 this.displayStatus('notice spinning',
4765 E('p', _('Reverting configuration…')));
4766
4767 request.request(L.url('admin/uci/revert'), {
4768 method: 'post',
4769 query: { sid: L.env.sessionid, token: L.env.token }
4770 }).then(function(r) {
4771 if (r.status === 200) {
4772 document.dispatchEvent(new CustomEvent('uci-reverted'));
4773
4774 UI.prototype.changes.setIndicator(0);
4775 UI.prototype.changes.displayStatus('notice',
4776 E('p', _('Changes have been reverted.')));
4777
4778 window.setTimeout(function() {
4779 //UI.prototype.changes.displayStatus(false);
4780 window.location = window.location.href.split('#')[0];
4781 }, L.env.apply_display * 1000);
4782 }
4783 else {
4784 UI.prototype.changes.displayStatus('warning',
4785 E('p', _('Revert request failed with status <code>%h</code>')
4786 .format(r.statusText || r.status)));
4787
4788 window.setTimeout(function() {
4789 UI.prototype.changes.displayStatus(false);
4790 }, L.env.apply_display * 1000);
4791 }
4792 });
4793 }
4794 }),
4795
4796 /**
4797 * Add validation constraints to an input element.
4798 *
4799 * Compile the given type expression and optional validator function into
4800 * a validation function and bind it to the specified input element events.
4801 *
4802 * @param {Node} field
4803 * The DOM input element node to bind the validation constraints to.
4804 *
4805 * @param {string} type
4806 * The datatype specification to describe validation constraints.
4807 * Refer to the `LuCI.validation` class documentation for details.
4808 *
4809 * @param {boolean} [optional=false]
4810 * Specifies whether empty values are allowed (`true`) or not (`false`).
4811 * If an input element is not marked optional it must not be empty,
4812 * otherwise it will be marked as invalid.
4813 *
4814 * @param {function} [vfunc]
4815 * Specifies a custom validation function which is invoked after the
4816 * other validation constraints are applied. The validation must return
4817 * `true` to accept the passed value. Any other return type is converted
4818 * to a string and treated as validation error message.
4819 *
4820 * @param {...string} [events=blur, keyup]
4821 * The list of events to bind. Each received event will trigger a field
4822 * validation. If omitted, the `keyup` and `blur` events are bound by
4823 * default.
4824 *
4825 * @returns {function}
4826 * Returns the compiled validator function which can be used to manually
4827 * trigger field validation or to bind it to further events.
4828 *
4829 * @see LuCI.validation
4830 */
4831 addValidator: function(field, type, optional, vfunc /*, ... */) {
4832 if (type == null)
4833 return;
4834
4835 var events = this.varargs(arguments, 3);
4836 if (events.length == 0)
4837 events.push('blur', 'keyup');
4838
4839 try {
4840 var cbiValidator = validation.create(field, type, optional, vfunc),
4841 validatorFn = cbiValidator.validate.bind(cbiValidator);
4842
4843 for (var i = 0; i < events.length; i++)
4844 field.addEventListener(events[i], validatorFn);
4845
4846 validatorFn();
4847
4848 return validatorFn;
4849 }
4850 catch (e) { }
4851 },
4852
4853 /**
4854 * Create a pre-bound event handler function.
4855 *
4856 * Generate and bind a function suitable for use in event handlers. The
4857 * generated function automatically disables the event source element
4858 * and adds an active indication to it by adding appropriate CSS classes.
4859 *
4860 * It will also await any promises returned by the wrapped function and
4861 * re-enable the source element after the promises ran to completion.
4862 *
4863 * @param {*} ctx
4864 * The `this` context to use for the wrapped function.
4865 *
4866 * @param {function|string} fn
4867 * Specifies the function to wrap. In case of a function value, the
4868 * function is used as-is. If a string is specified instead, it is looked
4869 * up in `ctx` to obtain the function to wrap. In both cases the bound
4870 * function will be invoked with `ctx` as `this` context
4871 *
4872 * @param {...*} extra_args
4873 * Any further parameter as passed as-is to the bound event handler
4874 * function in the same order as passed to `createHandlerFn()`.
4875 *
4876 * @returns {function|null}
4877 * Returns the pre-bound handler function which is suitable to be passed
4878 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4879 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4880 * valid function value.
4881 */
4882 createHandlerFn: function(ctx, fn /*, ... */) {
4883 if (typeof(fn) == 'string')
4884 fn = ctx[fn];
4885
4886 if (typeof(fn) != 'function')
4887 return null;
4888
4889 var arg_offset = arguments.length - 2;
4890
4891 return Function.prototype.bind.apply(function() {
4892 var t = arguments[arg_offset].currentTarget;
4893
4894 t.classList.add('spinning');
4895 t.disabled = true;
4896
4897 if (t.blur)
4898 t.blur();
4899
4900 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4901 t.classList.remove('spinning');
4902 t.disabled = false;
4903 });
4904 }, this.varargs(arguments, 2, ctx));
4905 },
4906
4907 /**
4908 * Load specified view class path and set it up.
4909 *
4910 * Transforms the given view path into a class name, requires it
4911 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4912 * resulting class instance is a descendant of
4913 * [LuCI.view]{@link LuCI.view}.
4914 *
4915 * By instantiating the view class, its corresponding contents are
4916 * rendered and included into the view area. Any runtime errors are
4917 * caught and rendered using [LuCI.error()]{@link LuCI#error}.
4918 *
4919 * @param {string} path
4920 * The view path to render.
4921 *
4922 * @returns {Promise<LuCI.view>}
4923 * Returns a promise resolving to the loaded view instance.
4924 */
4925 instantiateView: function(path) {
4926 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4927
4928 return L.require(className).then(function(view) {
4929 if (!(view instanceof View))
4930 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4931
4932 return view;
4933 }).catch(function(err) {
4934 dom.content(document.querySelector('#view'), null);
4935 L.error(err);
4936 });
4937 },
4938
4939 menu: UIMenu,
4940
4941 Table: UITable,
4942
4943 AbstractElement: UIElement,
4944
4945 /* Widgets */
4946 Textfield: UITextfield,
4947 Textarea: UITextarea,
4948 Checkbox: UICheckbox,
4949 Select: UISelect,
4950 Dropdown: UIDropdown,
4951 DynamicList: UIDynamicList,
4952 Combobox: UICombobox,
4953 ComboButton: UIComboButton,
4954 Hiddenfield: UIHiddenfield,
4955 FileUpload: UIFileUpload
4956 });
4957
4958 return UI;