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