luci-base: ui.js: rework dropdown focus behavior
[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 implicitely 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 unchaged. 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 implicitely 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 implicitely 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 implicitely 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 implicitely 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 {string} [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 implicitely 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('focus', this.handleFocus.bind(this));
1191
1192 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1193
1194 window.addEventListener('click', this.closeAllDropdowns);
1195 }
1196
1197 if (create) {
1198 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1199 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1200 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1201
1202 var li = findParent(create, 'li');
1203
1204 li.setAttribute('unselectable', '');
1205 li.addEventListener('click', this.handleCreateClick.bind(this));
1206 }
1207
1208 this.node = sb;
1209
1210 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1211 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1212
1213 dom.bindClassInstance(sb, this);
1214
1215 return sb;
1216 },
1217
1218 /** @private */
1219 getScrollParent: function(element) {
1220 var parent = element,
1221 style = getComputedStyle(element),
1222 excludeStaticParent = (style.position === 'absolute');
1223
1224 if (style.position === 'fixed')
1225 return document.body;
1226
1227 while ((parent = parent.parentElement) != null) {
1228 style = getComputedStyle(parent);
1229
1230 if (excludeStaticParent && style.position === 'static')
1231 continue;
1232
1233 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1234 return parent;
1235 }
1236
1237 return document.body;
1238 },
1239
1240 /** @private */
1241 openDropdown: function(sb) {
1242 var st = window.getComputedStyle(sb, null),
1243 ul = sb.querySelector('ul'),
1244 li = ul.querySelectorAll('li'),
1245 fl = findParent(sb, '.cbi-value-field'),
1246 sel = ul.querySelector('[selected]'),
1247 rect = sb.getBoundingClientRect(),
1248 items = Math.min(this.options.dropdown_items, li.length),
1249 scrollParent = this.getScrollParent(sb);
1250
1251 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1252 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1253 });
1254
1255 sb.setAttribute('open', '');
1256
1257 var pv = ul.cloneNode(true);
1258 pv.classList.add('preview');
1259
1260 if (fl)
1261 fl.classList.add('cbi-dropdown-open');
1262
1263 if ('ontouchstart' in window) {
1264 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1265 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1266 start = null;
1267
1268 ul.style.top = sb.offsetHeight + 'px';
1269 ul.style.left = -rect.left + 'px';
1270 ul.style.right = (rect.right - vpWidth) + 'px';
1271 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1272 ul.style.WebkitOverflowScrolling = 'touch';
1273
1274 var scrollFrom = scrollParent.scrollTop,
1275 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1276
1277 var scrollStep = function(timestamp) {
1278 if (!start) {
1279 start = timestamp;
1280 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1281 }
1282
1283 var duration = Math.max(timestamp - start, 1);
1284 if (duration < 100) {
1285 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1286 window.requestAnimationFrame(scrollStep);
1287 }
1288 else {
1289 scrollParent.scrollTop = scrollTo;
1290 }
1291 };
1292
1293 window.requestAnimationFrame(scrollStep);
1294 }
1295 else {
1296 ul.style.maxHeight = '1px';
1297 ul.style.top = ul.style.bottom = '';
1298
1299 window.requestAnimationFrame(function() {
1300 var containerRect = scrollParent.getBoundingClientRect(),
1301 itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1302 fullHeight = 0,
1303 spaceAbove = rect.top - containerRect.top,
1304 spaceBelow = containerRect.bottom - rect.bottom;
1305
1306 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1307 fullHeight += li[i].getBoundingClientRect().height;
1308
1309 if (fullHeight <= spaceBelow) {
1310 ul.style.top = rect.height + 'px';
1311 ul.style.maxHeight = spaceBelow + 'px';
1312 }
1313 else if (fullHeight <= spaceAbove) {
1314 ul.style.bottom = rect.height + 'px';
1315 ul.style.maxHeight = spaceAbove + 'px';
1316 }
1317 else if (spaceBelow >= spaceAbove) {
1318 ul.style.top = rect.height + 'px';
1319 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1320 }
1321 else {
1322 ul.style.bottom = rect.height + 'px';
1323 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1324 }
1325
1326 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1327 });
1328 }
1329
1330 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1331 for (var i = 0; i < cboxes.length; i++) {
1332 cboxes[i].checked = true;
1333 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1334 };
1335
1336 ul.classList.add('dropdown');
1337
1338 sb.insertBefore(pv, ul.nextElementSibling);
1339
1340 li.forEach(function(l) {
1341 if (!l.hasAttribute('unselectable'))
1342 l.setAttribute('tabindex', 0);
1343 });
1344
1345 sb.lastElementChild.setAttribute('tabindex', 0);
1346
1347 var focusFn = L.bind(function(el) {
1348 this.setFocus(sb, el, true);
1349 ul.removeEventListener('transitionend', focusFn);
1350 }, this, sel || li[0]);
1351
1352 ul.addEventListener('transitionend', focusFn);
1353 },
1354
1355 /** @private */
1356 closeDropdown: function(sb, no_focus) {
1357 if (!sb.hasAttribute('open'))
1358 return;
1359
1360 var pv = sb.querySelector('ul.preview'),
1361 ul = sb.querySelector('ul.dropdown'),
1362 li = ul.querySelectorAll('li'),
1363 fl = findParent(sb, '.cbi-value-field');
1364
1365 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1366 sb.lastElementChild.removeAttribute('tabindex');
1367
1368 sb.removeChild(pv);
1369 sb.removeAttribute('open');
1370 sb.style.width = sb.style.height = '';
1371
1372 ul.classList.remove('dropdown');
1373 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1374
1375 if (fl)
1376 fl.classList.remove('cbi-dropdown-open');
1377
1378 if (!no_focus)
1379 this.setFocus(sb, sb);
1380
1381 this.saveValues(sb, ul);
1382 },
1383
1384 /** @private */
1385 toggleItem: function(sb, li, force_state) {
1386 var ul = li.parentNode;
1387
1388 if (li.hasAttribute('unselectable'))
1389 return;
1390
1391 if (this.options.multiple) {
1392 var cbox = li.querySelector('input[type="checkbox"]'),
1393 items = li.parentNode.querySelectorAll('li'),
1394 label = sb.querySelector('ul.preview'),
1395 sel = li.parentNode.querySelectorAll('[selected]').length,
1396 more = sb.querySelector('.more'),
1397 ndisplay = this.options.display_items,
1398 n = 0;
1399
1400 if (li.hasAttribute('selected')) {
1401 if (force_state !== true) {
1402 if (sel > 1 || this.options.optional) {
1403 li.removeAttribute('selected');
1404 cbox.checked = cbox.disabled = false;
1405 sel--;
1406 }
1407 else {
1408 cbox.disabled = true;
1409 }
1410 }
1411 }
1412 else {
1413 if (force_state !== false) {
1414 li.setAttribute('selected', '');
1415 cbox.checked = true;
1416 cbox.disabled = false;
1417 sel++;
1418 }
1419 }
1420
1421 while (label && label.firstElementChild)
1422 label.removeChild(label.firstElementChild);
1423
1424 for (var i = 0; i < items.length; i++) {
1425 items[i].removeAttribute('display');
1426 if (items[i].hasAttribute('selected')) {
1427 if (ndisplay-- > 0) {
1428 items[i].setAttribute('display', n++);
1429 if (label)
1430 label.appendChild(items[i].cloneNode(true));
1431 }
1432 var c = items[i].querySelector('input[type="checkbox"]');
1433 if (c)
1434 c.disabled = (sel == 1 && !this.options.optional);
1435 }
1436 }
1437
1438 if (ndisplay < 0)
1439 sb.setAttribute('more', '');
1440 else
1441 sb.removeAttribute('more');
1442
1443 if (ndisplay === this.options.display_items)
1444 sb.setAttribute('empty', '');
1445 else
1446 sb.removeAttribute('empty');
1447
1448 dom.content(more, (ndisplay === this.options.display_items)
1449 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1450 }
1451 else {
1452 var sel = li.parentNode.querySelector('[selected]');
1453 if (sel) {
1454 sel.removeAttribute('display');
1455 sel.removeAttribute('selected');
1456 }
1457
1458 li.setAttribute('display', 0);
1459 li.setAttribute('selected', '');
1460
1461 this.closeDropdown(sb, true);
1462 }
1463
1464 this.saveValues(sb, ul);
1465 },
1466
1467 /** @private */
1468 transformItem: function(sb, li) {
1469 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1470 label = E('label');
1471
1472 while (li.firstChild)
1473 label.appendChild(li.firstChild);
1474
1475 li.appendChild(cbox);
1476 li.appendChild(label);
1477 },
1478
1479 /** @private */
1480 saveValues: function(sb, ul) {
1481 var sel = ul.querySelectorAll('li[selected]'),
1482 div = sb.lastElementChild,
1483 name = this.options.name,
1484 strval = '',
1485 values = [];
1486
1487 while (div.lastElementChild)
1488 div.removeChild(div.lastElementChild);
1489
1490 sel.forEach(function (s) {
1491 if (s.hasAttribute('placeholder'))
1492 return;
1493
1494 var v = {
1495 text: s.innerText,
1496 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1497 element: s
1498 };
1499
1500 div.appendChild(E('input', {
1501 type: 'hidden',
1502 name: name,
1503 value: v.value
1504 }));
1505
1506 values.push(v);
1507
1508 strval += strval.length ? ' ' + v.value : v.value;
1509 });
1510
1511 var detail = {
1512 instance: this,
1513 element: sb
1514 };
1515
1516 if (this.options.multiple)
1517 detail.values = values;
1518 else
1519 detail.value = values.length ? values[0] : null;
1520
1521 sb.value = strval;
1522
1523 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1524 bubbles: true,
1525 detail: detail
1526 }));
1527 },
1528
1529 /** @private */
1530 setValues: function(sb, values) {
1531 var ul = sb.querySelector('ul');
1532
1533 if (this.options.create) {
1534 for (var value in values) {
1535 this.createItems(sb, value);
1536
1537 if (!this.options.multiple)
1538 break;
1539 }
1540 }
1541
1542 if (this.options.multiple) {
1543 var lis = ul.querySelectorAll('li[data-value]');
1544 for (var i = 0; i < lis.length; i++) {
1545 var value = lis[i].getAttribute('data-value');
1546 if (values === null || !(value in values))
1547 this.toggleItem(sb, lis[i], false);
1548 else
1549 this.toggleItem(sb, lis[i], true);
1550 }
1551 }
1552 else {
1553 var ph = ul.querySelector('li[placeholder]');
1554 if (ph)
1555 this.toggleItem(sb, ph);
1556
1557 var lis = ul.querySelectorAll('li[data-value]');
1558 for (var i = 0; i < lis.length; i++) {
1559 var value = lis[i].getAttribute('data-value');
1560 if (values !== null && (value in values))
1561 this.toggleItem(sb, lis[i]);
1562 }
1563 }
1564 },
1565
1566 /** @private */
1567 setFocus: function(sb, elem, scroll) {
1568 if (sb.hasAttribute('locked-in'))
1569 return;
1570
1571 sb.querySelectorAll('.focus').forEach(function(e) {
1572 e.classList.remove('focus');
1573 });
1574
1575 elem.classList.add('focus');
1576
1577 if (scroll)
1578 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1579
1580 elem.focus();
1581 },
1582
1583 /** @private */
1584 createChoiceElement: function(sb, value, label) {
1585 var tpl = sb.querySelector(this.options.create_template),
1586 markup = null;
1587
1588 if (tpl)
1589 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|--!?>$/, '').trim();
1590 else
1591 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1592
1593 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1594 placeholder = new_item.querySelector('[data-label-placeholder]');
1595
1596 if (placeholder) {
1597 var content = E('span', {}, label || this.choices[value] || [ value ]);
1598
1599 while (content.firstChild)
1600 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1601
1602 placeholder.parentNode.removeChild(placeholder);
1603 }
1604
1605 if (this.options.multiple)
1606 this.transformItem(sb, new_item);
1607
1608 return new_item;
1609 },
1610
1611 /** @private */
1612 createItems: function(sb, value) {
1613 var sbox = this,
1614 val = (value || '').trim(),
1615 ul = sb.querySelector('ul');
1616
1617 if (!sbox.options.multiple)
1618 val = val.length ? [ val ] : [];
1619 else
1620 val = val.length ? val.split(/\s+/) : [];
1621
1622 val.forEach(function(item) {
1623 var new_item = null;
1624
1625 ul.childNodes.forEach(function(li) {
1626 if (li.getAttribute && li.getAttribute('data-value') === item)
1627 new_item = li;
1628 });
1629
1630 if (!new_item) {
1631 new_item = sbox.createChoiceElement(sb, item);
1632
1633 if (!sbox.options.multiple) {
1634 var old = ul.querySelector('li[created]');
1635 if (old)
1636 ul.removeChild(old);
1637
1638 new_item.setAttribute('created', '');
1639 }
1640
1641 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1642 }
1643
1644 sbox.toggleItem(sb, new_item, true);
1645 sbox.setFocus(sb, new_item, true);
1646 });
1647 },
1648
1649 /**
1650 * Remove all existing choices from the dropdown menu.
1651 *
1652 * This function removes all preexisting dropdown choices from the widget,
1653 * keeping only choices currently being selected unless `reset_values` is
1654 * given, in which case all choices and deselected and removed.
1655 *
1656 * @instance
1657 * @memberof LuCI.ui.Dropdown
1658 * @param {boolean} [reset_value=false]
1659 * If set to `true`, deselect and remove selected choices as well instead
1660 * of keeping them.
1661 */
1662 clearChoices: function(reset_value) {
1663 var ul = this.node.querySelector('ul'),
1664 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1665 len = lis.length - (this.options.create ? 1 : 0),
1666 val = reset_value ? null : this.getValue();
1667
1668 for (var i = 0; i < len; i++) {
1669 var lival = lis[i].getAttribute('data-value');
1670 if (val == null ||
1671 (!this.options.multiple && val != lival) ||
1672 (this.options.multiple && val.indexOf(lival) == -1))
1673 ul.removeChild(lis[i]);
1674 }
1675
1676 if (reset_value)
1677 this.setValues(this.node, {});
1678 },
1679
1680 /**
1681 * Add new choices to the dropdown menu.
1682 *
1683 * This function adds further choices to an existing dropdown menu,
1684 * ignoring choice values which are already present.
1685 *
1686 * @instance
1687 * @memberof LuCI.ui.Dropdown
1688 * @param {string[]} values
1689 * The choice values to add to the dropdown widget.
1690 *
1691 * @param {Object<string, *>} labels
1692 * The choice label values to use when adding dropdown choices. If no
1693 * label is found for a particular choice value, the value itself is used
1694 * as label text. Choice labels may be any valid value accepted by
1695 * {@link LuCI.dom#content}.
1696 */
1697 addChoices: function(values, labels) {
1698 var sb = this.node,
1699 ul = sb.querySelector('ul'),
1700 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1701
1702 if (!Array.isArray(values))
1703 values = L.toArray(values);
1704
1705 if (!L.isObject(labels))
1706 labels = {};
1707
1708 for (var i = 0; i < values.length; i++) {
1709 var found = false;
1710
1711 for (var j = 0; j < lis.length; j++) {
1712 if (lis[j].getAttribute('data-value') === values[i]) {
1713 found = true;
1714 break;
1715 }
1716 }
1717
1718 if (found)
1719 continue;
1720
1721 ul.insertBefore(
1722 this.createChoiceElement(sb, values[i], labels[values[i]]),
1723 ul.lastElementChild);
1724 }
1725 },
1726
1727 /**
1728 * Close all open dropdown widgets in the current document.
1729 */
1730 closeAllDropdowns: function() {
1731 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1732 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1733 });
1734 },
1735
1736 /** @private */
1737 handleClick: function(ev) {
1738 var sb = ev.currentTarget;
1739
1740 if (!sb.hasAttribute('open')) {
1741 if (!matchesElem(ev.target, 'input'))
1742 this.openDropdown(sb);
1743 }
1744 else {
1745 var li = findParent(ev.target, 'li');
1746 if (li && li.parentNode.classList.contains('dropdown'))
1747 this.toggleItem(sb, li);
1748 else if (li && li.parentNode.classList.contains('preview'))
1749 this.closeDropdown(sb);
1750 else if (matchesElem(ev.target, 'span.open, span.more'))
1751 this.closeDropdown(sb);
1752 }
1753
1754 ev.preventDefault();
1755 ev.stopPropagation();
1756 },
1757
1758 /** @private */
1759 handleKeydown: function(ev) {
1760 var sb = ev.currentTarget,
1761 ul = sb.querySelector('ul.dropdown');
1762
1763 if (matchesElem(ev.target, 'input'))
1764 return;
1765
1766 if (!sb.hasAttribute('open')) {
1767 switch (ev.keyCode) {
1768 case 37:
1769 case 38:
1770 case 39:
1771 case 40:
1772 this.openDropdown(sb);
1773 ev.preventDefault();
1774 }
1775 }
1776 else {
1777 var active = findParent(document.activeElement, 'li');
1778
1779 switch (ev.keyCode) {
1780 case 27:
1781 this.closeDropdown(sb);
1782 ev.stopPropagation();
1783 break;
1784
1785 case 13:
1786 if (active) {
1787 if (!active.hasAttribute('selected'))
1788 this.toggleItem(sb, active);
1789 this.closeDropdown(sb);
1790 ev.preventDefault();
1791 }
1792 break;
1793
1794 case 32:
1795 if (active) {
1796 this.toggleItem(sb, active);
1797 ev.preventDefault();
1798 }
1799 break;
1800
1801 case 38:
1802 if (active && active.previousElementSibling) {
1803 this.setFocus(sb, active.previousElementSibling);
1804 ev.preventDefault();
1805 }
1806 else if (document.activeElement === ul) {
1807 this.setFocus(sb, ul.lastElementChild);
1808 ev.preventDefault();
1809 }
1810 break;
1811
1812 case 40:
1813 if (active && active.nextElementSibling) {
1814 var li = active.nextElementSibling;
1815 this.setFocus(sb, li);
1816 if (this.options.create && li == li.parentNode.lastElementChild) {
1817 var input = li.querySelector('input');
1818 if (input) input.focus();
1819 }
1820 ev.preventDefault();
1821 }
1822 else if (document.activeElement === ul) {
1823 this.setFocus(sb, ul.firstElementChild);
1824 ev.preventDefault();
1825 }
1826 break;
1827 }
1828 }
1829 },
1830
1831 /** @private */
1832 handleDropdownClose: function(ev) {
1833 var sb = ev.currentTarget;
1834
1835 this.closeDropdown(sb, true);
1836 },
1837
1838 /** @private */
1839 handleDropdownSelect: function(ev) {
1840 var sb = ev.currentTarget,
1841 li = findParent(ev.target, 'li');
1842
1843 if (!li)
1844 return;
1845
1846 this.toggleItem(sb, li);
1847 this.closeDropdown(sb, true);
1848 },
1849
1850 /** @private */
1851 handleFocus: function(ev) {
1852 var sb = ev.currentTarget;
1853
1854 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1855 if (s !== sb || sb.hasAttribute('open'))
1856 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1857 });
1858 },
1859
1860 /** @private */
1861 handleCanaryFocus: function(ev) {
1862 this.closeDropdown(ev.currentTarget.parentNode);
1863 },
1864
1865 /** @private */
1866 handleCreateKeydown: function(ev) {
1867 var input = ev.currentTarget,
1868 li = findParent(input, 'li'),
1869 sb = findParent(li, '.cbi-dropdown');
1870
1871 switch (ev.keyCode) {
1872 case 13:
1873 ev.preventDefault();
1874
1875 if (input.classList.contains('cbi-input-invalid'))
1876 return;
1877
1878 this.createItems(sb, input.value);
1879 input.value = '';
1880 input.blur();
1881 break;
1882
1883 case 38:
1884 if (li.previousElementSibling) {
1885 this.handleCreateBlur(ev);
1886 this.setFocus(sb, li.previousElementSibling, true);
1887 }
1888 break;
1889 }
1890 },
1891
1892 /** @private */
1893 handleCreateFocus: function(ev) {
1894 var input = ev.currentTarget,
1895 li = findParent(input, 'li'),
1896 cbox = li.querySelector('input[type="checkbox"]'),
1897 sb = findParent(input, '.cbi-dropdown');
1898
1899 if (cbox)
1900 cbox.checked = true;
1901
1902 sb.setAttribute('locked-in', '');
1903 this.setFocus(sb, li, true);
1904 },
1905
1906 /** @private */
1907 handleCreateBlur: function(ev) {
1908 var input = ev.currentTarget,
1909 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1910 sb = findParent(input, '.cbi-dropdown');
1911
1912 if (cbox)
1913 cbox.checked = false;
1914
1915 sb.removeAttribute('locked-in');
1916 },
1917
1918 /** @private */
1919 handleCreateClick: function(ev) {
1920 ev.currentTarget.querySelector(this.options.create_query).focus();
1921 },
1922
1923 /** @override */
1924 setValue: function(values) {
1925 if (this.options.multiple) {
1926 if (!Array.isArray(values))
1927 values = (values != null && values != '') ? [ values ] : [];
1928
1929 var v = {};
1930
1931 for (var i = 0; i < values.length; i++)
1932 v[values[i]] = true;
1933
1934 this.setValues(this.node, v);
1935 }
1936 else {
1937 var v = {};
1938
1939 if (values != null) {
1940 if (Array.isArray(values))
1941 v[values[0]] = true;
1942 else
1943 v[values] = true;
1944 }
1945
1946 this.setValues(this.node, v);
1947 }
1948 },
1949
1950 /** @override */
1951 getValue: function() {
1952 var div = this.node.lastElementChild,
1953 h = div.querySelectorAll('input[type="hidden"]'),
1954 v = [];
1955
1956 for (var i = 0; i < h.length; i++)
1957 v.push(h[i].value);
1958
1959 return this.options.multiple ? v : v[0];
1960 }
1961 });
1962
1963 /**
1964 * Instantiate a rich dropdown choice widget allowing custom values.
1965 *
1966 * @constructor Combobox
1967 * @memberof LuCI.ui
1968 * @augments LuCI.ui.Dropdown
1969 *
1970 * @classdesc
1971 *
1972 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1973 * to enter custom values. Historically, comboboxes used to be a dedicated
1974 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1975 * with a set of enforced default properties for easier instantiation.
1976 *
1977 * UI widget instances are usually not supposed to be created by view code
1978 * directly, instead they're implicitely created by `LuCI.form` when
1979 * instantiating CBI forms.
1980 *
1981 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1982 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1983 * external JavaScript, use `L.require("ui").then(...)` and access the
1984 * `Combobox` property of the class instance value.
1985 *
1986 * @param {string|string[]} [value=null]
1987 * The initial input value(s).
1988 *
1989 * @param {Object<string, *>} choices
1990 * Object containing the selectable choices of the widget. The object keys
1991 * serve as values for the different choices while the values are used as
1992 * choice labels.
1993 *
1994 * @param {LuCI.ui.Combobox.InitOptions} [options]
1995 * Object describing the widget specific options to initialize the dropdown.
1996 */
1997 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1998 /**
1999 * Comboboxes support the same properties as
2000 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2001 * specific values for the following properties:
2002 *
2003 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2004 * @memberof LuCI.ui.Combobox
2005 *
2006 * @property {boolean} multiple=false
2007 * Since Comboboxes never allow selecting multiple values, this property
2008 * is forcibly set to `false`.
2009 *
2010 * @property {boolean} create=true
2011 * Since Comboboxes always allow custom choice values, this property is
2012 * forcibly set to `true`.
2013 *
2014 * @property {boolean} optional=true
2015 * Since Comboboxes are always optional, this property is forcibly set to
2016 * `true`.
2017 */
2018 __init__: function(value, choices, options) {
2019 this.super('__init__', [ value, choices, Object.assign({
2020 select_placeholder: _('-- Please choose --'),
2021 custom_placeholder: _('-- custom --'),
2022 dropdown_items: -1,
2023 sort: true
2024 }, options, {
2025 multiple: false,
2026 create: true,
2027 optional: true
2028 }) ]);
2029 }
2030 });
2031
2032 /**
2033 * Instantiate a combo button widget offering multiple action choices.
2034 *
2035 * @constructor ComboButton
2036 * @memberof LuCI.ui
2037 * @augments LuCI.ui.Dropdown
2038 *
2039 * @classdesc
2040 *
2041 * The `ComboButton` class implements a button element which can be expanded
2042 * into a dropdown to chose from a set of different action choices.
2043 *
2044 * UI widget instances are usually not supposed to be created by view code
2045 * directly, instead they're implicitely created by `LuCI.form` when
2046 * instantiating CBI forms.
2047 *
2048 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2049 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
2050 * external JavaScript, use `L.require("ui").then(...)` and access the
2051 * `ComboButton` property of the class instance value.
2052 *
2053 * @param {string|string[]} [value=null]
2054 * The initial input value(s).
2055 *
2056 * @param {Object<string, *>} choices
2057 * Object containing the selectable choices of the widget. The object keys
2058 * serve as values for the different choices while the values are used as
2059 * choice labels.
2060 *
2061 * @param {LuCI.ui.ComboButton.InitOptions} [options]
2062 * Object describing the widget specific options to initialize the button.
2063 */
2064 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
2065 /**
2066 * ComboButtons support the same properties as
2067 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
2068 * specific values for some properties and add aditional button specific
2069 * properties.
2070 *
2071 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2072 * @memberof LuCI.ui.ComboButton
2073 *
2074 * @property {boolean} multiple=false
2075 * Since ComboButtons never allow selecting multiple actions, this property
2076 * is forcibly set to `false`.
2077 *
2078 * @property {boolean} create=false
2079 * Since ComboButtons never allow creating custom choices, this property
2080 * is forcibly set to `false`.
2081 *
2082 * @property {boolean} optional=false
2083 * Since ComboButtons must always select one action, this property is
2084 * forcibly set to `false`.
2085 *
2086 * @property {Object<string, string>} [classes]
2087 * Specifies a mapping of choice values to CSS class names. If an action
2088 * choice is selected by the user and if a corresponding entry exists in
2089 * the `classes` object, the class names corresponding to the selected
2090 * value are set on the button element.
2091 *
2092 * This is useful to apply different button styles, such as colors, to the
2093 * combined button depending on the selected action.
2094 *
2095 * @property {function} [click]
2096 * Specifies a handler function to invoke when the user clicks the button.
2097 * This function will be called with the button DOM node as `this` context
2098 * and receive the DOM click event as first as well as the selected action
2099 * choice value as second argument.
2100 */
2101 __init__: function(value, choices, options) {
2102 this.super('__init__', [ value, choices, Object.assign({
2103 sort: true
2104 }, options, {
2105 multiple: false,
2106 create: false,
2107 optional: false
2108 }) ]);
2109 },
2110
2111 /** @override */
2112 render: function(/* ... */) {
2113 var node = UIDropdown.prototype.render.apply(this, arguments),
2114 val = this.getValue();
2115
2116 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2117 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2118
2119 return node;
2120 },
2121
2122 /** @private */
2123 handleClick: function(ev) {
2124 var sb = ev.currentTarget,
2125 t = ev.target;
2126
2127 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2128 return UIDropdown.prototype.handleClick.apply(this, arguments);
2129
2130 if (this.options.click)
2131 return this.options.click.call(sb, ev, this.getValue());
2132 },
2133
2134 /** @private */
2135 toggleItem: function(sb /*, ... */) {
2136 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2137 val = this.getValue();
2138
2139 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2140 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2141 else
2142 sb.setAttribute('class', 'cbi-dropdown');
2143
2144 return rv;
2145 }
2146 });
2147
2148 /**
2149 * Instantiate a dynamic list widget.
2150 *
2151 * @constructor DynamicList
2152 * @memberof LuCI.ui
2153 * @augments LuCI.ui.AbstractElement
2154 *
2155 * @classdesc
2156 *
2157 * The `DynamicList` class implements a widget which allows the user to specify
2158 * an arbitrary amount of input values, either from free formed text input or
2159 * from a set of predefined choices.
2160 *
2161 * UI widget instances are usually not supposed to be created by view code
2162 * directly, instead they're implicitely created by `LuCI.form` when
2163 * instantiating CBI forms.
2164 *
2165 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2166 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2167 * external JavaScript, use `L.require("ui").then(...)` and access the
2168 * `DynamicList` property of the class instance value.
2169 *
2170 * @param {string|string[]} [value=null]
2171 * The initial input value(s).
2172 *
2173 * @param {Object<string, *>} [choices]
2174 * Object containing the selectable choices of the widget. The object keys
2175 * serve as values for the different choices while the values are used as
2176 * choice labels. If omitted, no default choices are presented to the user,
2177 * instead a plain text input field is rendered allowing the user to add
2178 * arbitrary values to the dynamic list.
2179 *
2180 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2181 * Object describing the widget specific options to initialize the dynamic list.
2182 */
2183 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2184 /**
2185 * In case choices are passed to the dynamic list contructor, the widget
2186 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2187 * but enforces specific values for some dropdown properties.
2188 *
2189 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2190 * @memberof LuCI.ui.DynamicList
2191 *
2192 * @property {boolean} multiple=false
2193 * Since dynamic lists never allow selecting multiple choices when adding
2194 * another list item, this property is forcibly set to `false`.
2195 *
2196 * @property {boolean} optional=true
2197 * Since dynamic lists use an embedded dropdown to present a list of
2198 * predefined choice values, the dropdown must be made optional to allow
2199 * it to remain unselected.
2200 */
2201 __init__: function(values, choices, options) {
2202 if (!Array.isArray(values))
2203 values = (values != null && values != '') ? [ values ] : [];
2204
2205 if (typeof(choices) != 'object')
2206 choices = null;
2207
2208 this.values = values;
2209 this.choices = choices;
2210 this.options = Object.assign({}, options, {
2211 multiple: false,
2212 optional: true
2213 });
2214 },
2215
2216 /** @override */
2217 render: function() {
2218 var dl = E('div', {
2219 'id': this.options.id,
2220 'class': 'cbi-dynlist',
2221 'disabled': this.options.disabled ? '' : null
2222 }, E('div', { 'class': 'add-item control-group' }));
2223
2224 if (this.choices) {
2225 if (this.options.placeholder != null)
2226 this.options.select_placeholder = this.options.placeholder;
2227
2228 var cbox = new UICombobox(null, this.choices, this.options);
2229
2230 dl.lastElementChild.appendChild(cbox.render());
2231 }
2232 else {
2233 var inputEl = E('input', {
2234 'id': this.options.id ? 'widget.' + this.options.id : null,
2235 'type': 'text',
2236 'class': 'cbi-input-text',
2237 'placeholder': this.options.placeholder,
2238 'disabled': this.options.disabled ? '' : null
2239 });
2240
2241 dl.lastElementChild.appendChild(inputEl);
2242 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2243
2244 if (this.options.datatype || this.options.validate)
2245 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2246 true, this.options.validate, 'blur', 'keyup');
2247 }
2248
2249 for (var i = 0; i < this.values.length; i++) {
2250 var label = this.choices ? this.choices[this.values[i]] : null;
2251
2252 if (dom.elem(label))
2253 label = label.cloneNode(true);
2254
2255 this.addItem(dl, this.values[i], label);
2256 }
2257
2258 return this.bind(dl);
2259 },
2260
2261 /** @private */
2262 bind: function(dl) {
2263 dl.addEventListener('click', L.bind(this.handleClick, this));
2264 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2265 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2266
2267 this.node = dl;
2268
2269 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2270 this.setChangeEvents(dl, 'cbi-dynlist-change');
2271
2272 dom.bindClassInstance(dl, this);
2273
2274 return dl;
2275 },
2276
2277 /** @private */
2278 addItem: function(dl, value, text, flash) {
2279 var exists = false,
2280 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2281 E('span', {}, [ text || value ]),
2282 E('input', {
2283 'type': 'hidden',
2284 'name': this.options.name,
2285 'value': value })]);
2286
2287 dl.querySelectorAll('.item').forEach(function(item) {
2288 if (exists)
2289 return;
2290
2291 var hidden = item.querySelector('input[type="hidden"]');
2292
2293 if (hidden && hidden.parentNode !== item)
2294 hidden = null;
2295
2296 if (hidden && hidden.value === value)
2297 exists = true;
2298 });
2299
2300 if (!exists) {
2301 var ai = dl.querySelector('.add-item');
2302 ai.parentNode.insertBefore(new_item, ai);
2303 }
2304
2305 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2306 bubbles: true,
2307 detail: {
2308 instance: this,
2309 element: dl,
2310 value: value,
2311 add: true
2312 }
2313 }));
2314 },
2315
2316 /** @private */
2317 removeItem: function(dl, item) {
2318 var value = item.querySelector('input[type="hidden"]').value;
2319 var sb = dl.querySelector('.cbi-dropdown');
2320 if (sb)
2321 sb.querySelectorAll('ul > li').forEach(function(li) {
2322 if (li.getAttribute('data-value') === value) {
2323 if (li.hasAttribute('dynlistcustom'))
2324 li.parentNode.removeChild(li);
2325 else
2326 li.removeAttribute('unselectable');
2327 }
2328 });
2329
2330 item.parentNode.removeChild(item);
2331
2332 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2333 bubbles: true,
2334 detail: {
2335 instance: this,
2336 element: dl,
2337 value: value,
2338 remove: true
2339 }
2340 }));
2341 },
2342
2343 /** @private */
2344 handleClick: function(ev) {
2345 var dl = ev.currentTarget,
2346 item = findParent(ev.target, '.item');
2347
2348 if (this.options.disabled)
2349 return;
2350
2351 if (item) {
2352 this.removeItem(dl, item);
2353 }
2354 else if (matchesElem(ev.target, '.cbi-button-add')) {
2355 var input = ev.target.previousElementSibling;
2356 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2357 this.addItem(dl, input.value, null, true);
2358 input.value = '';
2359 }
2360 }
2361 },
2362
2363 /** @private */
2364 handleDropdownChange: function(ev) {
2365 var dl = ev.currentTarget,
2366 sbIn = ev.detail.instance,
2367 sbEl = ev.detail.element,
2368 sbVal = ev.detail.value;
2369
2370 if (sbVal === null)
2371 return;
2372
2373 sbIn.setValues(sbEl, null);
2374 sbVal.element.setAttribute('unselectable', '');
2375
2376 if (sbVal.element.hasAttribute('created')) {
2377 sbVal.element.removeAttribute('created');
2378 sbVal.element.setAttribute('dynlistcustom', '');
2379 }
2380
2381 var label = sbVal.text;
2382
2383 if (sbVal.element) {
2384 label = E([]);
2385
2386 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2387 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2388 }
2389
2390 this.addItem(dl, sbVal.value, label, true);
2391 },
2392
2393 /** @private */
2394 handleKeydown: function(ev) {
2395 var dl = ev.currentTarget,
2396 item = findParent(ev.target, '.item');
2397
2398 if (item) {
2399 switch (ev.keyCode) {
2400 case 8: /* backspace */
2401 if (item.previousElementSibling)
2402 item.previousElementSibling.focus();
2403
2404 this.removeItem(dl, item);
2405 break;
2406
2407 case 46: /* delete */
2408 if (item.nextElementSibling) {
2409 if (item.nextElementSibling.classList.contains('item'))
2410 item.nextElementSibling.focus();
2411 else
2412 item.nextElementSibling.firstElementChild.focus();
2413 }
2414
2415 this.removeItem(dl, item);
2416 break;
2417 }
2418 }
2419 else if (matchesElem(ev.target, '.cbi-input-text')) {
2420 switch (ev.keyCode) {
2421 case 13: /* enter */
2422 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2423 this.addItem(dl, ev.target.value, null, true);
2424 ev.target.value = '';
2425 ev.target.blur();
2426 ev.target.focus();
2427 }
2428
2429 ev.preventDefault();
2430 break;
2431 }
2432 }
2433 },
2434
2435 /** @override */
2436 getValue: function() {
2437 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2438 input = this.node.querySelector('.add-item > input[type="text"]'),
2439 v = [];
2440
2441 for (var i = 0; i < items.length; i++)
2442 v.push(items[i].value);
2443
2444 if (input && input.value != null && input.value.match(/\S/) &&
2445 input.classList.contains('cbi-input-invalid') == false &&
2446 v.filter(function(s) { return s == input.value }).length == 0)
2447 v.push(input.value);
2448
2449 return v;
2450 },
2451
2452 /** @override */
2453 setValue: function(values) {
2454 if (!Array.isArray(values))
2455 values = (values != null && values != '') ? [ values ] : [];
2456
2457 var items = this.node.querySelectorAll('.item');
2458
2459 for (var i = 0; i < items.length; i++)
2460 if (items[i].parentNode === this.node)
2461 this.removeItem(this.node, items[i]);
2462
2463 for (var i = 0; i < values.length; i++)
2464 this.addItem(this.node, values[i],
2465 this.choices ? this.choices[values[i]] : null);
2466 },
2467
2468 /**
2469 * Add new suggested choices to the dynamic list.
2470 *
2471 * This function adds further choices to an existing dynamic list,
2472 * ignoring choice values which are already present.
2473 *
2474 * @instance
2475 * @memberof LuCI.ui.DynamicList
2476 * @param {string[]} values
2477 * The choice values to add to the dynamic lists suggestion dropdown.
2478 *
2479 * @param {Object<string, *>} labels
2480 * The choice label values to use when adding suggested choices. If no
2481 * label is found for a particular choice value, the value itself is used
2482 * as label text. Choice labels may be any valid value accepted by
2483 * {@link LuCI.dom#content}.
2484 */
2485 addChoices: function(values, labels) {
2486 var dl = this.node.lastElementChild.firstElementChild;
2487 dom.callClassMethod(dl, 'addChoices', values, labels);
2488 },
2489
2490 /**
2491 * Remove all existing choices from the dynamic list.
2492 *
2493 * This function removes all preexisting suggested choices from the widget.
2494 *
2495 * @instance
2496 * @memberof LuCI.ui.DynamicList
2497 */
2498 clearChoices: function() {
2499 var dl = this.node.lastElementChild.firstElementChild;
2500 dom.callClassMethod(dl, 'clearChoices');
2501 }
2502 });
2503
2504 /**
2505 * Instantiate a hidden input field widget.
2506 *
2507 * @constructor Hiddenfield
2508 * @memberof LuCI.ui
2509 * @augments LuCI.ui.AbstractElement
2510 *
2511 * @classdesc
2512 *
2513 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2514 * which allows to store form data without exposing it to the user.
2515 *
2516 * UI widget instances are usually not supposed to be created by view code
2517 * directly, instead they're implicitely created by `LuCI.form` when
2518 * instantiating CBI forms.
2519 *
2520 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2521 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2522 * external JavaScript, use `L.require("ui").then(...)` and access the
2523 * `Hiddenfield` property of the class instance value.
2524 *
2525 * @param {string|string[]} [value=null]
2526 * The initial input value.
2527 *
2528 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2529 * Object describing the widget specific options to initialize the hidden input.
2530 */
2531 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2532 __init__: function(value, options) {
2533 this.value = value;
2534 this.options = Object.assign({
2535
2536 }, options);
2537 },
2538
2539 /** @override */
2540 render: function() {
2541 var hiddenEl = E('input', {
2542 'id': this.options.id,
2543 'type': 'hidden',
2544 'value': this.value
2545 });
2546
2547 return this.bind(hiddenEl);
2548 },
2549
2550 /** @private */
2551 bind: function(hiddenEl) {
2552 this.node = hiddenEl;
2553
2554 dom.bindClassInstance(hiddenEl, this);
2555
2556 return hiddenEl;
2557 },
2558
2559 /** @override */
2560 getValue: function() {
2561 return this.node.value;
2562 },
2563
2564 /** @override */
2565 setValue: function(value) {
2566 this.node.value = value;
2567 }
2568 });
2569
2570 /**
2571 * Instantiate a file upload widget.
2572 *
2573 * @constructor FileUpload
2574 * @memberof LuCI.ui
2575 * @augments LuCI.ui.AbstractElement
2576 *
2577 * @classdesc
2578 *
2579 * The `FileUpload` class implements a widget which allows the user to upload,
2580 * browse, select and delete files beneath a predefined remote directory.
2581 *
2582 * UI widget instances are usually not supposed to be created by view code
2583 * directly, instead they're implicitely created by `LuCI.form` when
2584 * instantiating CBI forms.
2585 *
2586 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2587 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2588 * external JavaScript, use `L.require("ui").then(...)` and access the
2589 * `FileUpload` property of the class instance value.
2590 *
2591 * @param {string|string[]} [value=null]
2592 * The initial input value.
2593 *
2594 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2595 * Object describing the widget specific options to initialize the file
2596 * upload control.
2597 */
2598 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2599 /**
2600 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2601 * the following properties are recognized:
2602 *
2603 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2604 * @memberof LuCI.ui.FileUpload
2605 *
2606 * @property {boolean} [show_hidden=false]
2607 * Specifies whether hidden files should be displayed when browsing remote
2608 * files. Note that this is not a security feature, hidden files are always
2609 * present in the remote file listings received, this option merely controls
2610 * whether they're displayed or not.
2611 *
2612 * @property {boolean} [enable_upload=true]
2613 * Specifies whether the widget allows the user to upload files. If set to
2614 * `false`, only existing files may be selected. Note that this is not a
2615 * security feature. Whether file upload requests are accepted remotely
2616 * depends on the ACL setup for the current session. This option merely
2617 * controls whether the upload controls are rendered or not.
2618 *
2619 * @property {boolean} [enable_remove=true]
2620 * Specifies whether the widget allows the user to delete remove files.
2621 * If set to `false`, existing files may not be removed. Note that this is
2622 * not a security feature. Whether file delete requests are accepted
2623 * remotely depends on the ACL setup for the current session. This option
2624 * merely controls whether the file remove controls are rendered or not.
2625 *
2626 * @property {string} [root_directory=/etc/luci-uploads]
2627 * Specifies the remote directory the upload and file browsing actions take
2628 * place in. Browsing to directories outside of the root directory is
2629 * prevented by the widget. Note that this is not a security feature.
2630 * Whether remote directories are browseable or not solely depends on the
2631 * ACL setup for the current session.
2632 */
2633 __init__: function(value, options) {
2634 this.value = value;
2635 this.options = Object.assign({
2636 show_hidden: false,
2637 enable_upload: true,
2638 enable_remove: true,
2639 root_directory: '/etc/luci-uploads'
2640 }, options);
2641 },
2642
2643 /** @private */
2644 bind: function(browserEl) {
2645 this.node = browserEl;
2646
2647 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2648 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2649
2650 dom.bindClassInstance(browserEl, this);
2651
2652 return browserEl;
2653 },
2654
2655 /** @override */
2656 render: function() {
2657 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2658 var label;
2659
2660 if (L.isObject(stat) && stat.type != 'directory')
2661 this.stat = stat;
2662
2663 if (this.stat != null)
2664 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2665 else if (this.value != null)
2666 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2667 else
2668 label = [ _('Select file…') ];
2669
2670 return this.bind(E('div', { 'id': this.options.id }, [
2671 E('button', {
2672 'class': 'btn',
2673 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2674 'disabled': this.options.disabled ? '' : null
2675 }, label),
2676 E('div', {
2677 'class': 'cbi-filebrowser'
2678 }),
2679 E('input', {
2680 'type': 'hidden',
2681 'name': this.options.name,
2682 'value': this.value
2683 })
2684 ]));
2685 }, this));
2686 },
2687
2688 /** @private */
2689 truncatePath: function(path) {
2690 if (path.length > 50)
2691 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2692
2693 return path;
2694 },
2695
2696 /** @private */
2697 iconForType: function(type) {
2698 switch (type) {
2699 case 'symlink':
2700 return E('img', {
2701 'src': L.resource('cbi/link.svg'),
2702 'width': 16,
2703 'title': _('Symbolic link'),
2704 'class': 'middle'
2705 });
2706
2707 case 'directory':
2708 return E('img', {
2709 'src': L.resource('cbi/folder.svg'),
2710 'width': 16,
2711 'title': _('Directory'),
2712 'class': 'middle'
2713 });
2714
2715 default:
2716 return E('img', {
2717 'src': L.resource('cbi/file.svg'),
2718 'width': 16,
2719 'title': _('File'),
2720 'class': 'middle'
2721 });
2722 }
2723 },
2724
2725 /** @private */
2726 canonicalizePath: function(path) {
2727 return path.replace(/\/{2,}/, '/')
2728 .replace(/\/\.(\/|$)/g, '/')
2729 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2730 .replace(/\/$/, '');
2731 },
2732
2733 /** @private */
2734 splitPath: function(path) {
2735 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2736 cpath = this.canonicalizePath(path || '/');
2737
2738 if (cpath.length <= croot.length)
2739 return [ croot ];
2740
2741 if (cpath.charAt(croot.length) != '/')
2742 return [ croot ];
2743
2744 var parts = cpath.substring(croot.length + 1).split(/\//);
2745
2746 parts.unshift(croot);
2747
2748 return parts;
2749 },
2750
2751 /** @private */
2752 handleUpload: function(path, list, ev) {
2753 var form = ev.target.parentNode,
2754 fileinput = form.querySelector('input[type="file"]'),
2755 nameinput = form.querySelector('input[type="text"]'),
2756 filename = (nameinput.value != null ? nameinput.value : '').trim();
2757
2758 ev.preventDefault();
2759
2760 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2761 return;
2762
2763 var existing = list.filter(function(e) { return e.name == filename })[0];
2764
2765 if (existing != null && existing.type == 'directory')
2766 return alert(_('A directory with the same name already exists.'));
2767 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2768 return;
2769
2770 var data = new FormData();
2771
2772 data.append('sessionid', L.env.sessionid);
2773 data.append('filename', path + '/' + filename);
2774 data.append('filedata', fileinput.files[0]);
2775
2776 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2777 progress: L.bind(function(btn, ev) {
2778 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2779 }, this, ev.target)
2780 }).then(L.bind(function(path, ev, res) {
2781 var reply = res.json();
2782
2783 if (L.isObject(reply) && reply.failure)
2784 alert(_('Upload request failed: %s').format(reply.message));
2785
2786 return this.handleSelect(path, null, ev);
2787 }, this, path, ev));
2788 },
2789
2790 /** @private */
2791 handleDelete: function(path, fileStat, ev) {
2792 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2793 name = path.replace(/^.+\//, ''),
2794 msg;
2795
2796 ev.preventDefault();
2797
2798 if (fileStat.type == 'directory')
2799 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2800 else
2801 msg = _('Do you really want to delete "%s" ?').format(name);
2802
2803 if (confirm(msg)) {
2804 var button = this.node.firstElementChild,
2805 hidden = this.node.lastElementChild;
2806
2807 if (path == hidden.value) {
2808 dom.content(button, _('Select file…'));
2809 hidden.value = '';
2810 }
2811
2812 return fs.remove(path).then(L.bind(function(parent, ev) {
2813 return this.handleSelect(parent, null, ev);
2814 }, this, parent, ev)).catch(function(err) {
2815 alert(_('Delete request failed: %s').format(err.message));
2816 });
2817 }
2818 },
2819
2820 /** @private */
2821 renderUpload: function(path, list) {
2822 if (!this.options.enable_upload)
2823 return E([]);
2824
2825 return E([
2826 E('a', {
2827 'href': '#',
2828 'class': 'btn cbi-button-positive',
2829 'click': function(ev) {
2830 var uploadForm = ev.target.nextElementSibling,
2831 fileInput = uploadForm.querySelector('input[type="file"]');
2832
2833 ev.target.style.display = 'none';
2834 uploadForm.style.display = '';
2835 fileInput.click();
2836 }
2837 }, _('Upload file…')),
2838 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2839 E('input', {
2840 'type': 'file',
2841 'style': 'display:none',
2842 'change': function(ev) {
2843 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2844 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2845
2846 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2847 uploadbtn.disabled = false;
2848 }
2849 }),
2850 E('button', {
2851 'class': 'btn',
2852 'click': function(ev) {
2853 ev.preventDefault();
2854 ev.target.previousElementSibling.click();
2855 }
2856 }, [ _('Browse…') ]),
2857 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2858 E('button', {
2859 'class': 'btn cbi-button-save',
2860 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2861 'disabled': true
2862 }, [ _('Upload file') ])
2863 ])
2864 ]);
2865 },
2866
2867 /** @private */
2868 renderListing: function(container, path, list) {
2869 var breadcrumb = E('p'),
2870 rows = E('ul');
2871
2872 list.sort(function(a, b) {
2873 return L.naturalCompare(a.type == 'directory', b.type == 'directory') ||
2874 L.naturalCompare(a.name, b.name);
2875 });
2876
2877 for (var i = 0; i < list.length; i++) {
2878 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2879 continue;
2880
2881 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2882 selected = (entrypath == this.node.lastElementChild.value),
2883 mtime = new Date(list[i].mtime * 1000);
2884
2885 rows.appendChild(E('li', [
2886 E('div', { 'class': 'name' }, [
2887 this.iconForType(list[i].type),
2888 ' ',
2889 E('a', {
2890 'href': '#',
2891 'style': selected ? 'font-weight:bold' : null,
2892 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2893 entrypath, list[i].type != 'directory' ? list[i] : null)
2894 }, '%h'.format(list[i].name))
2895 ]),
2896 E('div', { 'class': 'mtime hide-xs' }, [
2897 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2898 mtime.getFullYear(),
2899 mtime.getMonth() + 1,
2900 mtime.getDate(),
2901 mtime.getHours(),
2902 mtime.getMinutes(),
2903 mtime.getSeconds())
2904 ]),
2905 E('div', [
2906 selected ? E('button', {
2907 'class': 'btn',
2908 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2909 }, [ _('Deselect') ]) : '',
2910 this.options.enable_remove ? E('button', {
2911 'class': 'btn cbi-button-negative',
2912 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2913 }, [ _('Delete') ]) : ''
2914 ])
2915 ]));
2916 }
2917
2918 if (!rows.firstElementChild)
2919 rows.appendChild(E('em', _('No entries in this directory')));
2920
2921 var dirs = this.splitPath(path),
2922 cur = '';
2923
2924 for (var i = 0; i < dirs.length; i++) {
2925 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2926 dom.append(breadcrumb, [
2927 i ? ' » ' : '',
2928 E('a', {
2929 'href': '#',
2930 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2931 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2932 ]);
2933 }
2934
2935 dom.content(container, [
2936 breadcrumb,
2937 rows,
2938 E('div', { 'class': 'right' }, [
2939 this.renderUpload(path, list),
2940 E('a', {
2941 'href': '#',
2942 'class': 'btn',
2943 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2944 }, _('Cancel'))
2945 ]),
2946 ]);
2947 },
2948
2949 /** @private */
2950 handleCancel: function(ev) {
2951 var button = this.node.firstElementChild,
2952 browser = button.nextElementSibling;
2953
2954 browser.classList.remove('open');
2955 button.style.display = '';
2956
2957 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2958
2959 ev.preventDefault();
2960 },
2961
2962 /** @private */
2963 handleReset: function(ev) {
2964 var button = this.node.firstElementChild,
2965 hidden = this.node.lastElementChild;
2966
2967 hidden.value = '';
2968 dom.content(button, _('Select file…'));
2969
2970 this.handleCancel(ev);
2971 },
2972
2973 /** @private */
2974 handleSelect: function(path, fileStat, ev) {
2975 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2976 ul = browser.querySelector('ul');
2977
2978 if (fileStat == null) {
2979 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2980 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2981 }
2982 else {
2983 var button = this.node.firstElementChild,
2984 hidden = this.node.lastElementChild;
2985
2986 path = this.canonicalizePath(path);
2987
2988 dom.content(button, [
2989 this.iconForType(fileStat.type),
2990 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2991 ]);
2992
2993 browser.classList.remove('open');
2994 button.style.display = '';
2995 hidden.value = path;
2996
2997 this.stat = Object.assign({ path: path }, fileStat);
2998 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2999 }
3000 },
3001
3002 /** @private */
3003 handleFileBrowser: function(ev) {
3004 var button = ev.target,
3005 browser = button.nextElementSibling,
3006 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
3007
3008 if (path.indexOf(this.options.root_directory) != 0)
3009 path = this.options.root_directory;
3010
3011 ev.preventDefault();
3012
3013 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
3014 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
3015 dom.findClassInstance(browserEl).handleCancel(ev);
3016 });
3017
3018 button.style.display = 'none';
3019 browser.classList.add('open');
3020
3021 return this.renderListing(browser, path, list);
3022 }, this, button, browser, path));
3023 },
3024
3025 /** @override */
3026 getValue: function() {
3027 return this.node.lastElementChild.value;
3028 },
3029
3030 /** @override */
3031 setValue: function(value) {
3032 this.node.lastElementChild.value = value;
3033 }
3034 });
3035
3036
3037 function scrubMenu(node) {
3038 var hasSatisfiedChild = false;
3039
3040 if (L.isObject(node.children)) {
3041 for (var k in node.children) {
3042 var child = scrubMenu(node.children[k]);
3043
3044 if (child.title && !child.firstchild_ineligible)
3045 hasSatisfiedChild = hasSatisfiedChild || child.satisfied;
3046 }
3047 }
3048
3049 if (L.isObject(node.action) &&
3050 node.action.type == 'firstchild' &&
3051 hasSatisfiedChild == false)
3052 node.satisfied = false;
3053
3054 return node;
3055 };
3056
3057 /**
3058 * Handle menu.
3059 *
3060 * @constructor menu
3061 * @memberof LuCI.ui
3062 *
3063 * @classdesc
3064 *
3065 * Handles menus.
3066 */
3067 var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
3068 /**
3069 * @typedef {Object} MenuNode
3070 * @memberof LuCI.ui.menu
3071
3072 * @property {string} name - The internal name of the node, as used in the URL
3073 * @property {number} order - The sort index of the menu node
3074 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
3075 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
3076 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
3077 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
3078 */
3079
3080 /**
3081 * Load and cache current menu tree.
3082 *
3083 * @returns {Promise<LuCI.ui.menu.MenuNode>}
3084 * Returns a promise resolving to the root element of the menu tree.
3085 */
3086 load: function() {
3087 if (this.menu == null)
3088 this.menu = session.getLocalData('menu');
3089
3090 if (!L.isObject(this.menu)) {
3091 this.menu = request.get(L.url('admin/menu')).then(L.bind(function(menu) {
3092 this.menu = scrubMenu(menu.json());
3093 session.setLocalData('menu', this.menu);
3094
3095 return this.menu;
3096 }, this));
3097 }
3098
3099 return Promise.resolve(this.menu);
3100 },
3101
3102 /**
3103 * Flush the internal menu cache to force loading a new structure on the
3104 * next page load.
3105 */
3106 flushCache: function() {
3107 session.setLocalData('menu', null);
3108 },
3109
3110 /**
3111 * @param {LuCI.ui.menu.MenuNode} [node]
3112 * The menu node to retrieve the children for. Defaults to the menu's
3113 * internal root node if omitted.
3114 *
3115 * @returns {LuCI.ui.menu.MenuNode[]}
3116 * Returns an array of child menu nodes.
3117 */
3118 getChildren: function(node) {
3119 var children = [];
3120
3121 if (node == null)
3122 node = this.menu;
3123
3124 for (var k in node.children) {
3125 if (!node.children.hasOwnProperty(k))
3126 continue;
3127
3128 if (!node.children[k].satisfied)
3129 continue;
3130
3131 if (!node.children[k].hasOwnProperty('title'))
3132 continue;
3133
3134 var subnode = Object.assign(node.children[k], { name: k });
3135
3136 if (L.isObject(subnode.action) && subnode.action.path != null &&
3137 (subnode.action.type == 'alias' || subnode.action.type == 'rewrite')) {
3138 var root = this.menu,
3139 path = subnode.action.path.split('/');
3140
3141 for (var i = 0; root != null && i < path.length; i++)
3142 root = L.isObject(root.children) ? root.children[path[i]] : null;
3143
3144 if (root)
3145 subnode = Object.assign({}, subnode, {
3146 children: root.children,
3147 action: root.action
3148 });
3149 }
3150
3151 children.push(subnode);
3152 }
3153
3154 return children.sort(function(a, b) {
3155 var wA = a.order || 1000,
3156 wB = b.order || 1000;
3157
3158 if (wA != wB)
3159 return wA - wB;
3160
3161 return L.naturalCompare(a.name, b.name);
3162 });
3163 }
3164 });
3165
3166 var UITable = baseclass.extend(/** @lends LuCI.ui.table.prototype */ {
3167 __init__: function(captions, options, placeholder) {
3168 if (!Array.isArray(captions)) {
3169 this.initFromMarkup(captions);
3170
3171 return;
3172 }
3173
3174 var id = options.id || 'table%08x'.format(Math.random() * 0xffffffff);
3175
3176 var table = E('table', { 'id': id, 'class': 'table' }, [
3177 E('tr', { 'class': 'tr table-titles', 'click': UI.prototype.createHandlerFn(this, 'handleSort') })
3178 ]);
3179
3180 this.id = id;
3181 this.node = table
3182 this.options = options;
3183
3184 var sorting = this.getActiveSortState();
3185
3186 for (var i = 0; i < captions.length; i++) {
3187 if (captions[i] == null)
3188 continue;
3189
3190 var th = E('th', { 'class': 'th' }, [ captions[i] ]);
3191
3192 if (typeof(options.captionClasses) == 'object')
3193 DOMTokenList.prototype.add.apply(th.classList, L.toArray(options.captionClasses[i]));
3194
3195 if (options.sortable !== false && (typeof(options.sortable) != 'object' || options.sortable[i] !== false)) {
3196 th.setAttribute('data-sortable-row', true);
3197
3198 if (sorting && sorting[0] == i)
3199 th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc');
3200 }
3201
3202 table.firstElementChild.appendChild(th);
3203 }
3204
3205 if (placeholder) {
3206 var trow = table.appendChild(E('tr', { 'class': 'tr placeholder' })),
3207 td = trow.appendChild(E('td', { 'class': 'td' }, placeholder));
3208
3209 if (typeof(captionClasses) == 'object')
3210 DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
3211 }
3212
3213 DOMTokenList.prototype.add.apply(table.classList, L.toArray(options.classes));
3214 },
3215
3216 update: function(data, placeholder) {
3217 var placeholder = placeholder || this.options.placeholder || _('No data', 'empty table placeholder'),
3218 sorting = this.getActiveSortState();
3219
3220 if (!Array.isArray(data))
3221 return;
3222
3223 this.data = data;
3224 this.placeholder = placeholder;
3225
3226 var n = 0,
3227 rows = this.node.querySelectorAll('tr, .tr'),
3228 trows = [],
3229 headings = [].slice.call(this.node.firstElementChild.querySelectorAll('th, .th')),
3230 captionClasses = this.options.captionClasses,
3231 trTag = (rows[0] && rows[0].nodeName == 'DIV') ? 'div' : 'tr',
3232 tdTag = (headings[0] && headings[0].nodeName == 'DIV') ? 'div' : 'td';
3233
3234 if (sorting) {
3235 var list = data.map(L.bind(function(row) {
3236 return [ this.deriveSortKey(row[sorting[0]], sorting[0]), row ];
3237 }, this));
3238
3239 list.sort(function(a, b) {
3240 return sorting[1]
3241 ? -L.naturalCompare(a[0], b[0])
3242 : L.naturalCompare(a[0], b[0]);
3243 });
3244
3245 data.length = 0;
3246
3247 list.forEach(function(item) {
3248 data.push(item[1]);
3249 });
3250
3251 headings.forEach(function(th, i) {
3252 if (i == sorting[0])
3253 th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc');
3254 else
3255 th.removeAttribute('data-sort-direction');
3256 });
3257 }
3258
3259 data.forEach(function(row) {
3260 trows[n] = E(trTag, { 'class': 'tr' });
3261
3262 for (var i = 0; i < headings.length; i++) {
3263 var text = (headings[i].innerText || '').trim();
3264 var raw_val = Array.isArray(row[i]) ? row[i][0] : null;
3265 var disp_val = Array.isArray(row[i]) ? row[i][1] : row[i];
3266 var td = trows[n].appendChild(E(tdTag, {
3267 'class': 'td',
3268 'data-title': (text !== '') ? text : null,
3269 'data-value': raw_val
3270 }, (disp_val != null) ? ((disp_val instanceof DocumentFragment) ? disp_val.cloneNode(true) : disp_val) : ''));
3271
3272 if (typeof(captionClasses) == 'object')
3273 DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[i]));
3274
3275 if (!td.classList.contains('cbi-section-actions'))
3276 headings[i].setAttribute('data-sortable-row', true);
3277 }
3278
3279 trows[n].classList.add('cbi-rowstyle-%d'.format((n++ % 2) ? 2 : 1));
3280 });
3281
3282 for (var i = 0; i < n; i++) {
3283 if (rows[i+1])
3284 this.node.replaceChild(trows[i], rows[i+1]);
3285 else
3286 this.node.appendChild(trows[i]);
3287 }
3288
3289 while (rows[++n])
3290 this.node.removeChild(rows[n]);
3291
3292 if (placeholder && this.node.firstElementChild === this.node.lastElementChild) {
3293 var trow = this.node.appendChild(E(trTag, { 'class': 'tr placeholder' })),
3294 td = trow.appendChild(E(tdTag, { 'class': 'td' }, placeholder));
3295
3296 if (typeof(captionClasses) == 'object')
3297 DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
3298 }
3299
3300 return this.node;
3301 },
3302
3303 render: function() {
3304 return this.node;
3305 },
3306
3307 /** @private */
3308 initFromMarkup: function(node) {
3309 if (!dom.elem(node))
3310 node = document.querySelector(node);
3311
3312 if (!node)
3313 throw 'Invalid table selector';
3314
3315 var options = {},
3316 headrow = node.querySelector('tr, .tr');
3317
3318 if (!headrow)
3319 return;
3320
3321 options.id = node.id;
3322 options.classes = [].slice.call(node.classList).filter(function(c) { return c != 'table' });
3323 options.sortable = [];
3324 options.captionClasses = [];
3325
3326 headrow.querySelectorAll('th, .th').forEach(function(th, i) {
3327 options.sortable[i] = !th.classList.contains('cbi-section-actions');
3328 options.captionClasses[i] = [].slice.call(th.classList).filter(function(c) { return c != 'th' });
3329 });
3330
3331 headrow.addEventListener('click', UI.prototype.createHandlerFn(this, 'handleSort'));
3332
3333 this.id = node.id;
3334 this.node = node;
3335 this.options = options;
3336 },
3337
3338 /** @private */
3339 deriveSortKey: function(value, index) {
3340 var opts = this.options || {},
3341 hint, m;
3342
3343 if (opts.sortable == true || opts.sortable == null)
3344 hint = 'auto';
3345 else if (typeof( opts.sortable) == 'object')
3346 hint = opts.sortable[index];
3347
3348 if (dom.elem(value)) {
3349 if (value.hasAttribute('data-value'))
3350 value = value.getAttribute('data-value');
3351 else
3352 value = (value.innerText || '').trim();
3353 }
3354
3355 switch (hint || 'auto') {
3356 case true:
3357 case 'auto':
3358 m = /^([0-9a-fA-F:.]+)(?:\/([0-9a-fA-F:.]+))?$/.exec(value);
3359
3360 if (m) {
3361 var addr, mask;
3362
3363 addr = validation.parseIPv6(m[1]);
3364 mask = m[2] ? validation.parseIPv6(m[2]) : null;
3365
3366 if (addr && mask != null)
3367 return '%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x'.format(
3368 addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
3369 mask[0], mask[1], mask[2], mask[3], mask[4], mask[5], mask[6], mask[7]
3370 );
3371 else if (addr)
3372 return '%04x%04x%04x%04x%04x%04x%04x%04x%02x'.format(
3373 addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
3374 m[2] ? +m[2] : 128
3375 );
3376
3377 addr = validation.parseIPv4(m[1]);
3378 mask = m[2] ? validation.parseIPv4(m[2]) : null;
3379
3380 if (addr && mask != null)
3381 return '%03d%03d%03d%03d%03d%03d%03d%03d'.format(
3382 addr[0], addr[1], addr[2], addr[3],
3383 mask[0], mask[1], mask[2], mask[3]
3384 );
3385 else if (addr)
3386 return '%03d%03d%03d%03d%02d'.format(
3387 addr[0], addr[1], addr[2], addr[3],
3388 m[2] ? +m[2] : 32
3389 );
3390 }
3391
3392 m = /^(?:(\d+)d )?(\d+)h (\d+)m (\d+)s$/.exec(value);
3393
3394 if (m)
3395 return '%05d%02d%02d%02d'.format(+m[1], +m[2], +m[3], +m[4]);
3396
3397 m = /^(\d+)\b(\D*)$/.exec(value);
3398
3399 if (m)
3400 return '%010d%s'.format(+m[1], m[2]);
3401
3402 return String(value);
3403
3404 case 'ignorecase':
3405 return String(value).toLowerCase();
3406
3407 case 'numeric':
3408 return +value;
3409
3410 default:
3411 return String(value);
3412 }
3413 },
3414
3415 /** @private */
3416 getActiveSortState: function() {
3417 if (this.sortState)
3418 return this.sortState;
3419
3420 if (!this.options.id)
3421 return null;
3422
3423 var page = document.body.getAttribute('data-page'),
3424 key = page + '.' + this.options.id,
3425 state = session.getLocalData('tablesort');
3426
3427 if (L.isObject(state) && Array.isArray(state[key]))
3428 return state[key];
3429
3430 return null;
3431 },
3432
3433 /** @private */
3434 setActiveSortState: function(index, descending) {
3435 this.sortState = [ index, descending ];
3436
3437 if (!this.options.id)
3438 return;
3439
3440 var page = document.body.getAttribute('data-page'),
3441 key = page + '.' + this.options.id,
3442 state = session.getLocalData('tablesort');
3443
3444 if (!L.isObject(state))
3445 state = {};
3446
3447 state[key] = this.sortState;
3448
3449 session.setLocalData('tablesort', state);
3450 },
3451
3452 /** @private */
3453 handleSort: function(ev) {
3454 if (!ev.target.matches('th[data-sortable-row]'))
3455 return;
3456
3457 var index, direction;
3458
3459 this.node.firstElementChild.querySelectorAll('th, .th').forEach(function(th, i) {
3460 if (th === ev.target) {
3461 index = i;
3462 direction = th.getAttribute('data-sort-direction') == 'asc';
3463 }
3464 });
3465
3466 this.setActiveSortState(index, direction);
3467 this.update(this.data, this.placeholder);
3468 }
3469 });
3470
3471 /**
3472 * @class ui
3473 * @memberof LuCI
3474 * @hideconstructor
3475 * @classdesc
3476 *
3477 * Provides high level UI helper functionality.
3478 * To import the class in views, use `'require ui'`, to import it in
3479 * external JavaScript, use `L.require("ui").then(...)`.
3480 */
3481 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
3482 __init__: function() {
3483 modalDiv = document.body.appendChild(
3484 dom.create('div', {
3485 id: 'modal_overlay',
3486 tabindex: -1,
3487 keydown: this.cancelModal
3488 }, [
3489 dom.create('div', {
3490 class: 'modal',
3491 role: 'dialog',
3492 'aria-modal': true
3493 })
3494 ]));
3495
3496 tooltipDiv = document.body.appendChild(
3497 dom.create('div', { class: 'cbi-tooltip' }));
3498
3499 /* setup old aliases */
3500 L.showModal = this.showModal;
3501 L.hideModal = this.hideModal;
3502 L.showTooltip = this.showTooltip;
3503 L.hideTooltip = this.hideTooltip;
3504 L.itemlist = this.itemlist;
3505
3506 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
3507 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
3508 document.addEventListener('focus', this.showTooltip.bind(this), true);
3509 document.addEventListener('blur', this.hideTooltip.bind(this), true);
3510
3511 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
3512 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
3513 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
3514 },
3515
3516 /**
3517 * Display a modal overlay dialog with the specified contents.
3518 *
3519 * The modal overlay dialog covers the current view preventing interaction
3520 * with the underlying view contents. Only one modal dialog instance can
3521 * be opened. Invoking showModal() while a modal dialog is already open will
3522 * replace the open dialog with a new one having the specified contents.
3523 *
3524 * Additional CSS class names may be passed to influence the appearence of
3525 * the dialog. Valid values for the classes depend on the underlying theme.
3526 *
3527 * @see LuCI.dom.content
3528 *
3529 * @param {string} [title]
3530 * The title of the dialog. If `null`, no title element will be rendered.
3531 *
3532 * @param {*} contents
3533 * The contents to add to the modal dialog. This should be a DOM node or
3534 * a document fragment in most cases. The value is passed as-is to the
3535 * `dom.content()` function - refer to its documentation for applicable
3536 * values.
3537 *
3538 * @param {...string} [classes]
3539 * A number of extra CSS class names which are set on the modal dialog
3540 * element.
3541 *
3542 * @returns {Node}
3543 * Returns a DOM Node representing the modal dialog element.
3544 */
3545 showModal: function(title, children /* , ... */) {
3546 var dlg = modalDiv.firstElementChild;
3547
3548 dlg.setAttribute('class', 'modal');
3549
3550 for (var i = 2; i < arguments.length; i++)
3551 dlg.classList.add(arguments[i]);
3552
3553 dom.content(dlg, dom.create('h4', {}, title));
3554 dom.append(dlg, children);
3555
3556 document.body.classList.add('modal-overlay-active');
3557 modalDiv.scrollTop = 0;
3558 modalDiv.focus();
3559
3560 return dlg;
3561 },
3562
3563 /**
3564 * Close the open modal overlay dialog.
3565 *
3566 * This function will close an open modal dialog and restore the normal view
3567 * behaviour. It has no effect if no modal dialog is currently open.
3568 *
3569 * Note that this function is stand-alone, it does not rely on `this` and
3570 * will not invoke other class functions so it suitable to be used as event
3571 * handler as-is without the need to bind it first.
3572 */
3573 hideModal: function() {
3574 document.body.classList.remove('modal-overlay-active');
3575 modalDiv.blur();
3576 },
3577
3578 /** @private */
3579 cancelModal: function(ev) {
3580 if (ev.key == 'Escape') {
3581 var btn = modalDiv.querySelector('.right > button, .right > .btn');
3582
3583 if (btn)
3584 btn.click();
3585 }
3586 },
3587
3588 /** @private */
3589 showTooltip: function(ev) {
3590 var target = findParent(ev.target, '[data-tooltip]');
3591
3592 if (!target)
3593 return;
3594
3595 if (tooltipTimeout !== null) {
3596 window.clearTimeout(tooltipTimeout);
3597 tooltipTimeout = null;
3598 }
3599
3600 var rect = target.getBoundingClientRect(),
3601 x = rect.left + window.pageXOffset,
3602 y = rect.top + rect.height + window.pageYOffset,
3603 above = false;
3604
3605 tooltipDiv.className = 'cbi-tooltip';
3606 tooltipDiv.innerHTML = '▲ ';
3607 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3608
3609 if (target.hasAttribute('data-tooltip-style'))
3610 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3611
3612 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset))
3613 above = true;
3614
3615 var dropdown = target.querySelector('ul.dropdown[style]:first-child');
3616
3617 if (dropdown && dropdown.style.top)
3618 above = true;
3619
3620 if (above) {
3621 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3622 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
3623 }
3624
3625 tooltipDiv.style.top = y + 'px';
3626 tooltipDiv.style.left = x + 'px';
3627 tooltipDiv.style.opacity = 1;
3628
3629 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3630 bubbles: true,
3631 detail: { target: target }
3632 }));
3633 },
3634
3635 /** @private */
3636 hideTooltip: function(ev) {
3637 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3638 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3639 return;
3640
3641 if (tooltipTimeout !== null) {
3642 window.clearTimeout(tooltipTimeout);
3643 tooltipTimeout = null;
3644 }
3645
3646 tooltipDiv.style.opacity = 0;
3647 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3648
3649 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3650 },
3651
3652 /**
3653 * Add a notification banner at the top of the current view.
3654 *
3655 * A notification banner is an alert message usually displayed at the
3656 * top of the current view, spanning the entire availibe width.
3657 * Notification banners will stay in place until dismissed by the user.
3658 * Multiple banners may be shown at the same time.
3659 *
3660 * Additional CSS class names may be passed to influence the appearence of
3661 * the banner. Valid values for the classes depend on the underlying theme.
3662 *
3663 * @see LuCI.dom.content
3664 *
3665 * @param {string} [title]
3666 * The title of the notification banner. If `null`, no title element
3667 * will be rendered.
3668 *
3669 * @param {*} contents
3670 * The contents to add to the notification banner. This should be a DOM
3671 * node or a document fragment in most cases. The value is passed as-is
3672 * to the `dom.content()` function - refer to its documentation for
3673 * applicable values.
3674 *
3675 * @param {...string} [classes]
3676 * A number of extra CSS class names which are set on the notification
3677 * banner element.
3678 *
3679 * @returns {Node}
3680 * Returns a DOM Node representing the notification banner element.
3681 */
3682 addNotification: function(title, children /*, ... */) {
3683 var mc = document.querySelector('#maincontent') || document.body;
3684 var msg = E('div', {
3685 'class': 'alert-message fade-in',
3686 'style': 'display:flex',
3687 'transitionend': function(ev) {
3688 var node = ev.currentTarget;
3689 if (node.parentNode && node.classList.contains('fade-out'))
3690 node.parentNode.removeChild(node);
3691 }
3692 }, [
3693 E('div', { 'style': 'flex:10' }),
3694 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3695 E('button', {
3696 'class': 'btn',
3697 'style': 'margin-left:auto; margin-top:auto',
3698 'click': function(ev) {
3699 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3700 },
3701
3702 }, [ _('Dismiss') ])
3703 ])
3704 ]);
3705
3706 if (title != null)
3707 dom.append(msg.firstElementChild, E('h4', {}, title));
3708
3709 dom.append(msg.firstElementChild, children);
3710
3711 for (var i = 2; i < arguments.length; i++)
3712 msg.classList.add(arguments[i]);
3713
3714 mc.insertBefore(msg, mc.firstElementChild);
3715
3716 return msg;
3717 },
3718
3719 /**
3720 * Display or update an header area indicator.
3721 *
3722 * An indicator is a small label displayed in the header area of the screen
3723 * providing few amounts of status information such as item counts or state
3724 * toggle indicators.
3725 *
3726 * Multiple indicators may be shown at the same time and indicator labels
3727 * may be made clickable to display extended information or to initiate
3728 * further actions.
3729 *
3730 * Indicators can either use a default `active` or a less accented `inactive`
3731 * style which is useful for indicators representing state toggles.
3732 *
3733 * @param {string} id
3734 * The ID of the indicator. If an indicator with the given ID already exists,
3735 * it is updated with the given label and style.
3736 *
3737 * @param {string} label
3738 * The text to display in the indicator label.
3739 *
3740 * @param {function} [handler]
3741 * A handler function to invoke when the indicator label is clicked/touched
3742 * by the user. If omitted, the indicator is not clickable/touchable.
3743 *
3744 * Note that this parameter only applies to new indicators, when updating
3745 * existing labels it is ignored.
3746 *
3747 * @param {string} [style=active]
3748 * The indicator style to use. May be either `active` or `inactive`.
3749 *
3750 * @returns {boolean}
3751 * Returns `true` when the indicator has been updated or `false` when no
3752 * changes were made.
3753 */
3754 showIndicator: function(id, label, handler, style) {
3755 if (indicatorDiv == null) {
3756 indicatorDiv = document.body.querySelector('#indicators');
3757
3758 if (indicatorDiv == null)
3759 return false;
3760 }
3761
3762 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3763 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id));
3764
3765 if (indicatorElem == null) {
3766 var beforeElem = null;
3767
3768 for (beforeElem = indicatorDiv.firstElementChild;
3769 beforeElem != null;
3770 beforeElem = beforeElem.nextElementSibling)
3771 if (beforeElem.getAttribute('data-indicator') > id)
3772 break;
3773
3774 indicatorElem = indicatorDiv.insertBefore(E('span', {
3775 'data-indicator': id,
3776 'data-clickable': handlerFn ? true : null,
3777 'click': handlerFn
3778 }, ['']), beforeElem);
3779 }
3780
3781 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3782 return false;
3783
3784 indicatorElem.firstChild.data = label;
3785 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3786 return true;
3787 },
3788
3789 /**
3790 * Remove an header area indicator.
3791 *
3792 * This function removes the given indicator label from the header indicator
3793 * area. When the given indicator is not found, this function does nothing.
3794 *
3795 * @param {string} id
3796 * The ID of the indicator to remove.
3797 *
3798 * @returns {boolean}
3799 * Returns `true` when the indicator has been removed or `false` when the
3800 * requested indicator was not found.
3801 */
3802 hideIndicator: function(id) {
3803 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3804
3805 if (indicatorElem == null)
3806 return false;
3807
3808 indicatorDiv.removeChild(indicatorElem);
3809 return true;
3810 },
3811
3812 /**
3813 * Formats a series of label/value pairs into list-like markup.
3814 *
3815 * This function transforms a flat array of alternating label and value
3816 * elements into a list-like markup, using the values in `separators` as
3817 * separators and appends the resulting nodes to the given parent DOM node.
3818 *
3819 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3820 * `<strong>` element and the value corresponding to the label are
3821 * subsequently wrapped into a `<span class="nowrap">` element.
3822 *
3823 * The resulting `<span>` element tuples are joined by the given separators
3824 * to form the final markup which is appened to the given parent DOM node.
3825 *
3826 * @param {Node} node
3827 * The parent DOM node to append the markup to. Any previous child elements
3828 * will be removed.
3829 *
3830 * @param {Array<*>} items
3831 * An alternating array of labels and values. The label values will be
3832 * converted to plain strings, the values are used as-is and may be of
3833 * any type accepted by `LuCI.dom.content()`.
3834 *
3835 * @param {*|Array<*>} [separators=[E('br')]]
3836 * A single value or an array of separator values to separate each
3837 * label/value pair with. The function will cycle through the separators
3838 * when joining the pairs. If omitted, the default separator is a sole HTML
3839 * `<br>` element. Separator values are used as-is and may be of any type
3840 * accepted by `LuCI.dom.content()`.
3841 *
3842 * @returns {Node}
3843 * Returns the parent DOM node the formatted markup has been added to.
3844 */
3845 itemlist: function(node, items, separators) {
3846 var children = [];
3847
3848 if (!Array.isArray(separators))
3849 separators = [ separators || E('br') ];
3850
3851 for (var i = 0; i < items.length; i += 2) {
3852 if (items[i+1] !== null && items[i+1] !== undefined) {
3853 var sep = separators[(i/2) % separators.length],
3854 cld = [];
3855
3856 children.push(E('span', { class: 'nowrap' }, [
3857 items[i] ? E('strong', items[i] + ': ') : '',
3858 items[i+1]
3859 ]));
3860
3861 if ((i+2) < items.length)
3862 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3863 }
3864 }
3865
3866 dom.content(node, children);
3867
3868 return node;
3869 },
3870
3871 /**
3872 * @class
3873 * @memberof LuCI.ui
3874 * @hideconstructor
3875 * @classdesc
3876 *
3877 * The `tabs` class handles tab menu groups used throughout the view area.
3878 * It takes care of setting up tab groups, tracking their state and handling
3879 * related events.
3880 *
3881 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3882 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3883 * external JavaScript, use `L.require("ui").then(...)` and access the
3884 * `tabs` property of the class instance value.
3885 */
3886 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3887 /** @private */
3888 init: function() {
3889 var groups = [], prevGroup = null, currGroup = null;
3890
3891 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3892 var parent = tab.parentNode;
3893
3894 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3895 return;
3896
3897 if (!parent.hasAttribute('data-tab-group'))
3898 parent.setAttribute('data-tab-group', groups.length);
3899
3900 currGroup = +parent.getAttribute('data-tab-group');
3901
3902 if (currGroup !== prevGroup) {
3903 prevGroup = currGroup;
3904
3905 if (!groups[currGroup])
3906 groups[currGroup] = [];
3907 }
3908
3909 groups[currGroup].push(tab);
3910 });
3911
3912 for (var i = 0; i < groups.length; i++)
3913 this.initTabGroup(groups[i]);
3914
3915 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3916
3917 this.updateTabs();
3918 },
3919
3920 /**
3921 * Initializes a new tab group from the given tab pane collection.
3922 *
3923 * This function cycles through the given tab pane DOM nodes, extracts
3924 * their tab IDs, titles and active states, renders a corresponding
3925 * tab menu and prepends it to the tab panes common parent DOM node.
3926 *
3927 * The tab menu labels will be set to the value of the `data-tab-title`
3928 * attribute of each corresponding pane. The last pane with the
3929 * `data-tab-active` attribute set to `true` will be selected by default.
3930 *
3931 * If no pane is marked as active, the first one will be preselected.
3932 *
3933 * @instance
3934 * @memberof LuCI.ui.tabs
3935 * @param {Array<Node>|NodeList} panes
3936 * A collection of tab panes to build a tab group menu for. May be a
3937 * plain array of DOM nodes or a NodeList collection, such as the result
3938 * of a `querySelectorAll()` call or the `.childNodes` property of a
3939 * DOM node.
3940 */
3941 initTabGroup: function(panes) {
3942 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3943 return;
3944
3945 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3946 group = panes[0].parentNode,
3947 groupId = +group.getAttribute('data-tab-group'),
3948 selected = null;
3949
3950 if (group.getAttribute('data-initialized') === 'true')
3951 return;
3952
3953 for (var i = 0, pane; pane = panes[i]; i++) {
3954 var name = pane.getAttribute('data-tab'),
3955 title = pane.getAttribute('data-tab-title'),
3956 active = pane.getAttribute('data-tab-active') === 'true';
3957
3958 menu.appendChild(E('li', {
3959 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3960 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3961 'data-tab': name
3962 }, E('a', {
3963 'href': '#',
3964 'click': this.switchTab.bind(this)
3965 }, title)));
3966
3967 if (active)
3968 selected = i;
3969 }
3970
3971 group.parentNode.insertBefore(menu, group);
3972 group.setAttribute('data-initialized', true);
3973
3974 if (selected === null) {
3975 selected = this.getActiveTabId(panes[0]);
3976
3977 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3978 for (var i = 0; i < panes.length; i++) {
3979 if (!this.isEmptyPane(panes[i])) {
3980 selected = i;
3981 break;
3982 }
3983 }
3984 }
3985
3986 menu.childNodes[selected].classList.add('cbi-tab');
3987 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3988 panes[selected].setAttribute('data-tab-active', 'true');
3989
3990 this.setActiveTabId(panes[selected], selected);
3991 }
3992
3993 requestAnimationFrame(L.bind(function(pane) {
3994 pane.dispatchEvent(new CustomEvent('cbi-tab-active', {
3995 detail: { tab: pane.getAttribute('data-tab') }
3996 }));
3997 }, this, panes[selected]));
3998
3999 this.updateTabs(group);
4000 },
4001
4002 /**
4003 * Checks whether the given tab pane node is empty.
4004 *
4005 * @instance
4006 * @memberof LuCI.ui.tabs
4007 * @param {Node} pane
4008 * The tab pane to check.
4009 *
4010 * @returns {boolean}
4011 * Returns `true` if the pane is empty, else `false`.
4012 */
4013 isEmptyPane: function(pane) {
4014 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
4015 },
4016
4017 /** @private */
4018 getPathForPane: function(pane) {
4019 var path = [], node = null;
4020
4021 for (node = pane ? pane.parentNode : null;
4022 node != null && node.hasAttribute != null;
4023 node = node.parentNode)
4024 {
4025 if (node.hasAttribute('data-tab'))
4026 path.unshift(node.getAttribute('data-tab'));
4027 else if (node.hasAttribute('data-section-id'))
4028 path.unshift(node.getAttribute('data-section-id'));
4029 }
4030
4031 return path.join('/');
4032 },
4033
4034 /** @private */
4035 getActiveTabState: function() {
4036 var page = document.body.getAttribute('data-page'),
4037 state = session.getLocalData('tab');
4038
4039 if (L.isObject(state) && state.page === page && L.isObject(state.paths))
4040 return state;
4041
4042 session.setLocalData('tab', null);
4043
4044 return { page: page, paths: {} };
4045 },
4046
4047 /** @private */
4048 getActiveTabId: function(pane) {
4049 var path = this.getPathForPane(pane);
4050 return +this.getActiveTabState().paths[path] || 0;
4051 },
4052
4053 /** @private */
4054 setActiveTabId: function(pane, tabIndex) {
4055 var path = this.getPathForPane(pane),
4056 state = this.getActiveTabState();
4057
4058 state.paths[path] = tabIndex;
4059
4060 return session.setLocalData('tab', state);
4061 },
4062
4063 /** @private */
4064 updateTabs: function(ev, root) {
4065 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
4066 var menu = pane.parentNode.previousElementSibling,
4067 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
4068 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
4069
4070 if (!menu || !tab)
4071 return;
4072
4073 if (this.isEmptyPane(pane)) {
4074 tab.style.display = 'none';
4075 tab.classList.remove('flash');
4076 }
4077 else if (tab.style.display === 'none') {
4078 tab.style.display = '';
4079 requestAnimationFrame(function() { tab.classList.add('flash') });
4080 }
4081
4082 if (n_errors) {
4083 tab.setAttribute('data-errors', n_errors);
4084 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
4085 tab.setAttribute('data-tooltip-style', 'error');
4086 }
4087 else {
4088 tab.removeAttribute('data-errors');
4089 tab.removeAttribute('data-tooltip');
4090 }
4091 }, this));
4092 },
4093
4094 /** @private */
4095 switchTab: function(ev) {
4096 var tab = ev.target.parentNode,
4097 name = tab.getAttribute('data-tab'),
4098 menu = tab.parentNode,
4099 group = menu.nextElementSibling,
4100 groupId = +group.getAttribute('data-tab-group'),
4101 index = 0;
4102
4103 ev.preventDefault();
4104
4105 if (!tab.classList.contains('cbi-tab-disabled'))
4106 return;
4107
4108 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
4109 tab.classList.remove('cbi-tab');
4110 tab.classList.remove('cbi-tab-disabled');
4111 tab.classList.add(
4112 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
4113 });
4114
4115 group.childNodes.forEach(function(pane) {
4116 if (dom.matches(pane, '[data-tab]')) {
4117 if (pane.getAttribute('data-tab') === name) {
4118 pane.setAttribute('data-tab-active', 'true');
4119 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
4120 UI.prototype.tabs.setActiveTabId(pane, index);
4121 }
4122 else {
4123 pane.setAttribute('data-tab-active', 'false');
4124 }
4125
4126 index++;
4127 }
4128 });
4129 }
4130 }),
4131
4132 /**
4133 * @typedef {Object} FileUploadReply
4134 * @memberof LuCI.ui
4135
4136 * @property {string} name - Name of the uploaded file without directory components
4137 * @property {number} size - Size of the uploaded file in bytes
4138 * @property {string} checksum - The MD5 checksum of the received file data
4139 * @property {string} sha256sum - The SHA256 checksum of the received file data
4140 */
4141
4142 /**
4143 * Display a modal file upload prompt.
4144 *
4145 * This function opens a modal dialog prompting the user to select and
4146 * upload a file to a predefined remote destination path.
4147 *
4148 * @param {string} path
4149 * The remote file path to upload the local file to.
4150 *
4151 * @param {Node} [progessStatusNode]
4152 * An optional DOM text node whose content text is set to the progress
4153 * percentage value during file upload.
4154 *
4155 * @returns {Promise<LuCI.ui.FileUploadReply>}
4156 * Returns a promise resolving to a file upload status object on success
4157 * or rejecting with an error in case the upload failed or has been
4158 * cancelled by the user.
4159 */
4160 uploadFile: function(path, progressStatusNode) {
4161 return new Promise(function(resolveFn, rejectFn) {
4162 UI.prototype.showModal(_('Uploading file…'), [
4163 E('p', _('Please select the file to upload.')),
4164 E('div', { 'style': 'display:flex' }, [
4165 E('div', { 'class': 'left', 'style': 'flex:1' }, [
4166 E('input', {
4167 type: 'file',
4168 style: 'display:none',
4169 change: function(ev) {
4170 var modal = dom.parent(ev.target, '.modal'),
4171 body = modal.querySelector('p'),
4172 upload = modal.querySelector('.cbi-button-action.important'),
4173 file = ev.currentTarget.files[0];
4174
4175 if (file == null)
4176 return;
4177
4178 dom.content(body, [
4179 E('ul', {}, [
4180 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
4181 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
4182 ])
4183 ]);
4184
4185 upload.disabled = false;
4186 upload.focus();
4187 }
4188 }),
4189 E('button', {
4190 'class': 'btn',
4191 'click': function(ev) {
4192 ev.target.previousElementSibling.click();
4193 }
4194 }, [ _('Browse…') ])
4195 ]),
4196 E('div', { 'class': 'right', 'style': 'flex:1' }, [
4197 E('button', {
4198 'class': 'btn',
4199 'click': function() {
4200 UI.prototype.hideModal();
4201 rejectFn(new Error(_('Upload has been cancelled')));
4202 }
4203 }, [ _('Cancel') ]),
4204 ' ',
4205 E('button', {
4206 'class': 'btn cbi-button-action important',
4207 'disabled': true,
4208 'click': function(ev) {
4209 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
4210
4211 if (!input.files[0])
4212 return;
4213
4214 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
4215
4216 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
4217
4218 var data = new FormData();
4219
4220 data.append('sessionid', rpc.getSessionID());
4221 data.append('filename', path);
4222 data.append('filedata', input.files[0]);
4223
4224 var filename = input.files[0].name;
4225
4226 request.post(L.env.cgi_base + '/cgi-upload', data, {
4227 timeout: 0,
4228 progress: function(pev) {
4229 var percent = (pev.loaded / pev.total) * 100;
4230
4231 if (progressStatusNode)
4232 progressStatusNode.data = '%.2f%%'.format(percent);
4233
4234 progress.setAttribute('title', '%.2f%%'.format(percent));
4235 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
4236 }
4237 }).then(function(res) {
4238 var reply = res.json();
4239
4240 UI.prototype.hideModal();
4241
4242 if (L.isObject(reply) && reply.failure) {
4243 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
4244 rejectFn(new Error(reply.failure));
4245 }
4246 else {
4247 reply.name = filename;
4248 resolveFn(reply);
4249 }
4250 }, function(err) {
4251 UI.prototype.hideModal();
4252 rejectFn(err);
4253 });
4254 }
4255 }, [ _('Upload') ])
4256 ])
4257 ])
4258 ]);
4259 });
4260 },
4261
4262 /**
4263 * Perform a device connectivity test.
4264 *
4265 * Attempt to fetch a well known ressource from the remote device via HTTP
4266 * in order to test connectivity. This function is mainly useful to wait
4267 * for the router to come back online after a reboot or reconfiguration.
4268 *
4269 * @param {string} [proto=http]
4270 * The protocol to use for fetching the resource. May be either `http`
4271 * (the default) or `https`.
4272 *
4273 * @param {string} [host=window.location.host]
4274 * Override the host address to probe. By default the current host as seen
4275 * in the address bar is probed.
4276 *
4277 * @returns {Promise<Event>}
4278 * Returns a promise resolving to a `load` event in case the device is
4279 * reachable or rejecting with an `error` event in case it is not reachable
4280 * or rejecting with `null` when the connectivity check timed out.
4281 */
4282 pingDevice: function(proto, ipaddr) {
4283 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
4284
4285 return new Promise(function(resolveFn, rejectFn) {
4286 var img = new Image();
4287
4288 img.onload = resolveFn;
4289 img.onerror = rejectFn;
4290
4291 window.setTimeout(rejectFn, 1000);
4292
4293 img.src = target;
4294 });
4295 },
4296
4297 /**
4298 * Wait for device to come back online and reconnect to it.
4299 *
4300 * Poll each given hostname or IP address and navigate to it as soon as
4301 * one of the addresses becomes reachable.
4302 *
4303 * @param {...string} [hosts=[window.location.host]]
4304 * The list of IP addresses and host names to check for reachability.
4305 * If omitted, the current value of `window.location.host` is used by
4306 * default.
4307 */
4308 awaitReconnect: function(/* ... */) {
4309 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
4310
4311 window.setTimeout(L.bind(function() {
4312 poll.add(L.bind(function() {
4313 var tasks = [], reachable = false;
4314
4315 for (var i = 0; i < 2; i++)
4316 for (var j = 0; j < ipaddrs.length; j++)
4317 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
4318 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
4319
4320 return Promise.all(tasks).then(function() {
4321 if (reachable) {
4322 poll.stop();
4323 window.location = reachable;
4324 }
4325 });
4326 }, this));
4327 }, this), 5000);
4328 },
4329
4330 /**
4331 * @class
4332 * @memberof LuCI.ui
4333 * @hideconstructor
4334 * @classdesc
4335 *
4336 * The `changes` class encapsulates logic for visualizing, applying,
4337 * confirming and reverting staged UCI changesets.
4338 *
4339 * This class is automatically instantiated as part of `LuCI.ui`. To use it
4340 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
4341 * external JavaScript, use `L.require("ui").then(...)` and access the
4342 * `changes` property of the class instance value.
4343 */
4344 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
4345 init: function() {
4346 if (!L.env.sessionid)
4347 return;
4348
4349 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
4350 },
4351
4352 /**
4353 * Set the change count indicator.
4354 *
4355 * This function updates or hides the UCI change count indicator,
4356 * depending on the passed change count. When the count is greater
4357 * than 0, the change indicator is displayed or updated, otherwise it
4358 * is removed.
4359 *
4360 * @instance
4361 * @memberof LuCI.ui.changes
4362 * @param {number} numChanges
4363 * The number of changes to indicate.
4364 */
4365 setIndicator: function(n) {
4366 if (n > 0) {
4367 UI.prototype.showIndicator('uci-changes',
4368 '%s: %d'.format(_('Unsaved Changes'), n),
4369 L.bind(this.displayChanges, this));
4370 }
4371 else {
4372 UI.prototype.hideIndicator('uci-changes');
4373 }
4374 },
4375
4376 /**
4377 * Update the change count indicator.
4378 *
4379 * This function updates the UCI change count indicator from the given
4380 * UCI changeset structure.
4381 *
4382 * @instance
4383 * @memberof LuCI.ui.changes
4384 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
4385 * The UCI changeset to count.
4386 */
4387 renderChangeIndicator: function(changes) {
4388 var n_changes = 0;
4389
4390 for (var config in changes)
4391 if (changes.hasOwnProperty(config))
4392 n_changes += changes[config].length;
4393
4394 this.changes = changes;
4395 this.setIndicator(n_changes);
4396 },
4397
4398 /** @private */
4399 changeTemplates: {
4400 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
4401 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
4402 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
4403 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
4404 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
4405 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
4406 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
4407 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
4408 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
4409 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
4410 },
4411
4412 /**
4413 * Display the current changelog.
4414 *
4415 * Open a modal dialog visualizing the currently staged UCI changes
4416 * and offer options to revert or apply the shown changes.
4417 *
4418 * @instance
4419 * @memberof LuCI.ui.changes
4420 */
4421 displayChanges: function() {
4422 var list = E('div', { 'class': 'uci-change-list' }),
4423 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
4424 E('div', { 'class': 'cbi-section' }, [
4425 E('strong', _('Legend:')),
4426 E('div', { 'class': 'uci-change-legend' }, [
4427 E('div', { 'class': 'uci-change-legend-label' }, [
4428 E('ins', '&#160;'), ' ', _('Section added') ]),
4429 E('div', { 'class': 'uci-change-legend-label' }, [
4430 E('del', '&#160;'), ' ', _('Section removed') ]),
4431 E('div', { 'class': 'uci-change-legend-label' }, [
4432 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
4433 E('div', { 'class': 'uci-change-legend-label' }, [
4434 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
4435 E('br'), list,
4436 E('div', { 'class': 'right' }, [
4437 E('button', {
4438 'class': 'btn',
4439 'click': UI.prototype.hideModal
4440 }, [ _('Close') ]), ' ',
4441 new UIComboButton('0', {
4442 0: [ _('Save & Apply') ],
4443 1: [ _('Apply unchecked') ]
4444 }, {
4445 classes: {
4446 0: 'btn cbi-button cbi-button-positive important',
4447 1: 'btn cbi-button cbi-button-negative important'
4448 },
4449 click: L.bind(function(ev, mode) { this.apply(mode == '0') }, this)
4450 }).render(), ' ',
4451 E('button', {
4452 'class': 'cbi-button cbi-button-reset',
4453 'click': L.bind(this.revert, this)
4454 }, [ _('Revert') ])])])
4455 ]);
4456
4457 for (var config in this.changes) {
4458 if (!this.changes.hasOwnProperty(config))
4459 continue;
4460
4461 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
4462
4463 for (var i = 0, added = null; i < this.changes[config].length; i++) {
4464 var chg = this.changes[config][i],
4465 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
4466
4467 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
4468 switch (+m1) {
4469 case 0:
4470 return config;
4471
4472 case 2:
4473 if (added != null && chg[1] == added[0])
4474 return '@' + added[1] + '[-1]';
4475 else
4476 return chg[1];
4477
4478 case 4:
4479 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
4480
4481 default:
4482 return chg[m1-1];
4483 }
4484 })));
4485
4486 if (chg[0] == 'add')
4487 added = [ chg[1], chg[2] ];
4488 }
4489 }
4490
4491 list.appendChild(E('br'));
4492 dlg.classList.add('uci-dialog');
4493 },
4494
4495 /** @private */
4496 displayStatus: function(type, content) {
4497 if (type) {
4498 var message = UI.prototype.showModal('', '');
4499
4500 message.classList.add('alert-message');
4501 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4502
4503 if (content)
4504 dom.content(message, content);
4505
4506 if (!this.was_polling) {
4507 this.was_polling = request.poll.active();
4508 request.poll.stop();
4509 }
4510 }
4511 else {
4512 UI.prototype.hideModal();
4513
4514 if (this.was_polling)
4515 request.poll.start();
4516 }
4517 },
4518
4519 /** @private */
4520 checkConnectivityAffected: function() {
4521 return L.resolveDefault(fs.exec_direct('/usr/libexec/luci-peeraddr', null, 'json')).then(L.bind(function(info) {
4522 if (L.isObject(info) && Array.isArray(info.inbound_interfaces)) {
4523 for (var i = 0; i < info.inbound_interfaces.length; i++) {
4524 var iif = info.inbound_interfaces[i];
4525
4526 for (var j = 0; this.changes && this.changes.network && j < this.changes.network.length; j++) {
4527 var chg = this.changes.network[j];
4528
4529 if (chg[0] == 'set' && chg[1] == iif &&
4530 ((chg[2] == 'disabled' && chg[3] == '1') || chg[2] == 'proto' || chg[2] == 'ipaddr' || chg[2] == 'netmask'))
4531 return iif;
4532 }
4533 }
4534 }
4535
4536 return null;
4537 }, this));
4538 },
4539
4540 /** @private */
4541 rollback: function(checked) {
4542 if (checked) {
4543 this.displayStatus('warning spinning',
4544 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4545 .format(L.env.apply_rollback)));
4546
4547 var call = function(r, data, duration) {
4548 if (r.status === 204) {
4549 UI.prototype.changes.displayStatus('warning', [
4550 E('h4', _('Configuration changes have been rolled back!')),
4551 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)),
4552 E('div', { 'class': 'right' }, [
4553 E('button', {
4554 'class': 'btn',
4555 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4556 }, [ _('Dismiss') ]), ' ',
4557 E('button', {
4558 'class': 'btn cbi-button-action important',
4559 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4560 }, [ _('Revert changes') ]), ' ',
4561 E('button', {
4562 'class': 'btn cbi-button-negative important',
4563 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4564 }, [ _('Apply unchecked') ])
4565 ])
4566 ]);
4567
4568 return;
4569 }
4570
4571 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4572 window.setTimeout(function() {
4573 request.request(L.url('admin/uci/confirm'), {
4574 method: 'post',
4575 timeout: L.env.apply_timeout * 1000,
4576 query: { sid: L.env.sessionid, token: L.env.token }
4577 }).then(call, call.bind(null, { status: 0 }, null, 0));
4578 }, delay);
4579 };
4580
4581 call({ status: 0 });
4582 }
4583 else {
4584 this.displayStatus('warning', [
4585 E('h4', _('Device unreachable!')),
4586 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.'))
4587 ]);
4588 }
4589 },
4590
4591 /** @private */
4592 confirm: function(checked, deadline, override_token) {
4593 var tt;
4594 var ts = Date.now();
4595
4596 this.displayStatus('notice');
4597
4598 if (override_token)
4599 this.confirm_auth = { token: override_token };
4600
4601 var call = function(r, data, duration) {
4602 if (Date.now() >= deadline) {
4603 window.clearTimeout(tt);
4604 UI.prototype.changes.rollback(checked);
4605 return;
4606 }
4607 else if (r && (r.status === 200 || r.status === 204)) {
4608 document.dispatchEvent(new CustomEvent('uci-applied'));
4609
4610 UI.prototype.changes.setIndicator(0);
4611 UI.prototype.changes.displayStatus('notice',
4612 E('p', _('Configuration changes applied.')));
4613
4614 window.clearTimeout(tt);
4615 window.setTimeout(function() {
4616 //UI.prototype.changes.displayStatus(false);
4617 window.location = window.location.href.split('#')[0];
4618 }, L.env.apply_display * 1000);
4619
4620 return;
4621 }
4622
4623 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4624 window.setTimeout(function() {
4625 request.request(L.url('admin/uci/confirm'), {
4626 method: 'post',
4627 timeout: L.env.apply_timeout * 1000,
4628 query: UI.prototype.changes.confirm_auth
4629 }).then(call, call);
4630 }, delay);
4631 };
4632
4633 var tick = function() {
4634 var now = Date.now();
4635
4636 UI.prototype.changes.displayStatus('notice spinning',
4637 E('p', _('Applying configuration changes… %ds')
4638 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4639
4640 if (now >= deadline)
4641 return;
4642
4643 tt = window.setTimeout(tick, 1000 - (now - ts));
4644 ts = now;
4645 };
4646
4647 tick();
4648
4649 /* wait a few seconds for the settings to become effective */
4650 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4651 },
4652
4653 /**
4654 * Apply the staged configuration changes.
4655 *
4656 * Start applying staged configuration changes and open a modal dialog
4657 * with a progress indication to prevent interaction with the view
4658 * during the apply process. The modal dialog will be automatically
4659 * closed and the current view reloaded once the apply process is
4660 * complete.
4661 *
4662 * @instance
4663 * @memberof LuCI.ui.changes
4664 * @param {boolean} [checked=false]
4665 * Whether to perform a checked (`true`) configuration apply or an
4666 * unchecked (`false`) one.
4667
4668 * In case of a checked apply, the configuration changes must be
4669 * confirmed within a specific time interval, otherwise the device
4670 * will begin to roll back the changes in order to restore the previous
4671 * settings.
4672 */
4673 apply: function(checked) {
4674 this.displayStatus('notice spinning',
4675 E('p', _('Starting configuration apply…')));
4676
4677 (new Promise(function(resolveFn, rejectFn) {
4678 if (!checked)
4679 return resolveFn(false);
4680
4681 UI.prototype.changes.checkConnectivityAffected().then(function(affected) {
4682 if (!affected)
4683 return resolveFn(true);
4684
4685 UI.prototype.changes.displayStatus('warning', [
4686 E('h4', _('Connectivity change')),
4687 E('p', _('The network access to this device could be interrupted by changing settings of the "%h" interface.').format(affected)),
4688 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)),
4689 E('div', { 'class': 'right' }, [
4690 E('button', {
4691 'class': 'btn',
4692 'click': rejectFn,
4693 }, [ _('Cancel') ]), ' ',
4694 E('button', {
4695 'class': 'btn cbi-button-action important',
4696 'click': resolveFn.bind(null, true)
4697 }, [ _('Apply with revert after connectivity loss') ]), ' ',
4698 E('button', {
4699 'class': 'btn cbi-button-negative important',
4700 'click': resolveFn.bind(null, false)
4701 }, [ _('Apply and keep settings') ])
4702 ])
4703 ]);
4704 });
4705 })).then(function(checked) {
4706 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4707 method: 'post',
4708 query: { sid: L.env.sessionid, token: L.env.token }
4709 }).then(function(r) {
4710 if (r.status === (checked ? 200 : 204)) {
4711 var tok = null; try { tok = r.json(); } catch(e) {}
4712 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4713 UI.prototype.changes.confirm_auth = tok;
4714
4715 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4716 }
4717 else if (checked && r.status === 204) {
4718 UI.prototype.changes.displayStatus('notice',
4719 E('p', _('There are no changes to apply')));
4720
4721 window.setTimeout(function() {
4722 UI.prototype.changes.displayStatus(false);
4723 }, L.env.apply_display * 1000);
4724 }
4725 else {
4726 UI.prototype.changes.displayStatus('warning',
4727 E('p', _('Apply request failed with status <code>%h</code>')
4728 .format(r.responseText || r.statusText || r.status)));
4729
4730 window.setTimeout(function() {
4731 UI.prototype.changes.displayStatus(false);
4732 }, L.env.apply_display * 1000);
4733 }
4734 });
4735 }, this.displayStatus.bind(this, false));
4736 },
4737
4738 /**
4739 * Revert the staged configuration changes.
4740 *
4741 * Start reverting staged configuration changes and open a modal dialog
4742 * with a progress indication to prevent interaction with the view
4743 * during the revert process. The modal dialog will be automatically
4744 * closed and the current view reloaded once the revert process is
4745 * complete.
4746 *
4747 * @instance
4748 * @memberof LuCI.ui.changes
4749 */
4750 revert: function() {
4751 this.displayStatus('notice spinning',
4752 E('p', _('Reverting configuration…')));
4753
4754 request.request(L.url('admin/uci/revert'), {
4755 method: 'post',
4756 query: { sid: L.env.sessionid, token: L.env.token }
4757 }).then(function(r) {
4758 if (r.status === 200) {
4759 document.dispatchEvent(new CustomEvent('uci-reverted'));
4760
4761 UI.prototype.changes.setIndicator(0);
4762 UI.prototype.changes.displayStatus('notice',
4763 E('p', _('Changes have been reverted.')));
4764
4765 window.setTimeout(function() {
4766 //UI.prototype.changes.displayStatus(false);
4767 window.location = window.location.href.split('#')[0];
4768 }, L.env.apply_display * 1000);
4769 }
4770 else {
4771 UI.prototype.changes.displayStatus('warning',
4772 E('p', _('Revert request failed with status <code>%h</code>')
4773 .format(r.statusText || r.status)));
4774
4775 window.setTimeout(function() {
4776 UI.prototype.changes.displayStatus(false);
4777 }, L.env.apply_display * 1000);
4778 }
4779 });
4780 }
4781 }),
4782
4783 /**
4784 * Add validation constraints to an input element.
4785 *
4786 * Compile the given type expression and optional validator function into
4787 * a validation function and bind it to the specified input element events.
4788 *
4789 * @param {Node} field
4790 * The DOM input element node to bind the validation constraints to.
4791 *
4792 * @param {string} type
4793 * The datatype specification to describe validation constraints.
4794 * Refer to the `LuCI.validation` class documentation for details.
4795 *
4796 * @param {boolean} [optional=false]
4797 * Specifies whether empty values are allowed (`true`) or not (`false`).
4798 * If an input element is not marked optional it must not be empty,
4799 * otherwise it will be marked as invalid.
4800 *
4801 * @param {function} [vfunc]
4802 * Specifies a custom validation function which is invoked after the
4803 * other validation constraints are applied. The validation must return
4804 * `true` to accept the passed value. Any other return type is converted
4805 * to a string and treated as validation error message.
4806 *
4807 * @param {...string} [events=blur, keyup]
4808 * The list of events to bind. Each received event will trigger a field
4809 * validation. If omitted, the `keyup` and `blur` events are bound by
4810 * default.
4811 *
4812 * @returns {function}
4813 * Returns the compiled validator function which can be used to manually
4814 * trigger field validation or to bind it to further events.
4815 *
4816 * @see LuCI.validation
4817 */
4818 addValidator: function(field, type, optional, vfunc /*, ... */) {
4819 if (type == null)
4820 return;
4821
4822 var events = this.varargs(arguments, 3);
4823 if (events.length == 0)
4824 events.push('blur', 'keyup');
4825
4826 try {
4827 var cbiValidator = validation.create(field, type, optional, vfunc),
4828 validatorFn = cbiValidator.validate.bind(cbiValidator);
4829
4830 for (var i = 0; i < events.length; i++)
4831 field.addEventListener(events[i], validatorFn);
4832
4833 validatorFn();
4834
4835 return validatorFn;
4836 }
4837 catch (e) { }
4838 },
4839
4840 /**
4841 * Create a pre-bound event handler function.
4842 *
4843 * Generate and bind a function suitable for use in event handlers. The
4844 * generated function automatically disables the event source element
4845 * and adds an active indication to it by adding appropriate CSS classes.
4846 *
4847 * It will also await any promises returned by the wrapped function and
4848 * re-enable the source element after the promises ran to completion.
4849 *
4850 * @param {*} ctx
4851 * The `this` context to use for the wrapped function.
4852 *
4853 * @param {function|string} fn
4854 * Specifies the function to wrap. In case of a function value, the
4855 * function is used as-is. If a string is specified instead, it is looked
4856 * up in `ctx` to obtain the function to wrap. In both cases the bound
4857 * function will be invoked with `ctx` as `this` context
4858 *
4859 * @param {...*} extra_args
4860 * Any further parameter as passed as-is to the bound event handler
4861 * function in the same order as passed to `createHandlerFn()`.
4862 *
4863 * @returns {function|null}
4864 * Returns the pre-bound handler function which is suitable to be passed
4865 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4866 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4867 * valid function value.
4868 */
4869 createHandlerFn: function(ctx, fn /*, ... */) {
4870 if (typeof(fn) == 'string')
4871 fn = ctx[fn];
4872
4873 if (typeof(fn) != 'function')
4874 return null;
4875
4876 var arg_offset = arguments.length - 2;
4877
4878 return Function.prototype.bind.apply(function() {
4879 var t = arguments[arg_offset].currentTarget;
4880
4881 t.classList.add('spinning');
4882 t.disabled = true;
4883
4884 if (t.blur)
4885 t.blur();
4886
4887 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4888 t.classList.remove('spinning');
4889 t.disabled = false;
4890 });
4891 }, this.varargs(arguments, 2, ctx));
4892 },
4893
4894 /**
4895 * Load specified view class path and set it up.
4896 *
4897 * Transforms the given view path into a class name, requires it
4898 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4899 * resulting class instance is a descendant of
4900 * [LuCI.view]{@link LuCI.view}.
4901 *
4902 * By instantiating the view class, its corresponding contents are
4903 * rendered and included into the view area. Any runtime errors are
4904 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4905 *
4906 * @param {string} path
4907 * The view path to render.
4908 *
4909 * @returns {Promise<LuCI.view>}
4910 * Returns a promise resolving to the loaded view instance.
4911 */
4912 instantiateView: function(path) {
4913 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4914
4915 return L.require(className).then(function(view) {
4916 if (!(view instanceof View))
4917 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4918
4919 return view;
4920 }).catch(function(err) {
4921 dom.content(document.querySelector('#view'), null);
4922 L.error(err);
4923 });
4924 },
4925
4926 menu: UIMenu,
4927
4928 Table: UITable,
4929
4930 AbstractElement: UIElement,
4931
4932 /* Widgets */
4933 Textfield: UITextfield,
4934 Textarea: UITextarea,
4935 Checkbox: UICheckbox,
4936 Select: UISelect,
4937 Dropdown: UIDropdown,
4938 DynamicList: UIDynamicList,
4939 Combobox: UICombobox,
4940 ComboButton: UIComboButton,
4941 Hiddenfield: UIHiddenfield,
4942 FileUpload: UIFileUpload
4943 });
4944
4945 return UI;