luci-base: ui.js: implement Textarea widget
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2 'require uci';
3 'require validation';
4
5 var modalDiv = null,
6 tooltipDiv = null,
7 tooltipTimeout = null;
8
9 var UIElement = L.Class.extend({
10 getValue: function() {
11 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
12 return this.node.value;
13
14 return null;
15 },
16
17 setValue: function(value) {
18 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
19 this.node.value = value;
20 },
21
22 isValid: function() {
23 return (this.validState !== false);
24 },
25
26 triggerValidation: function() {
27 if (typeof(this.vfunc) != 'function')
28 return false;
29
30 var wasValid = this.isValid();
31
32 this.vfunc();
33
34 return (wasValid != this.isValid());
35 },
36
37 registerEvents: function(targetNode, synevent, events) {
38 var dispatchFn = L.bind(function(ev) {
39 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
40 }, this);
41
42 for (var i = 0; i < events.length; i++)
43 targetNode.addEventListener(events[i], dispatchFn);
44 },
45
46 setUpdateEvents: function(targetNode /*, ... */) {
47 var datatype = this.options.datatype,
48 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
49 validate = this.options.validate,
50 events = this.varargs(arguments, 1);
51
52 this.registerEvents(targetNode, 'widget-update', events);
53
54 if (!datatype && !validate)
55 return;
56
57 this.vfunc = L.ui.addValidator.apply(L.ui, [
58 targetNode, datatype || 'string',
59 optional, validate
60 ].concat(events));
61
62 this.node.addEventListener('validation-success', L.bind(function(ev) {
63 this.validState = true;
64 }, this));
65
66 this.node.addEventListener('validation-failure', L.bind(function(ev) {
67 this.validState = false;
68 }, this));
69 },
70
71 setChangeEvents: function(targetNode /*, ... */) {
72 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
73 }
74 });
75
76 var UITextfield = UIElement.extend({
77 __init__: function(value, options) {
78 this.value = value;
79 this.options = Object.assign({
80 optional: true,
81 password: false
82 }, options);
83 },
84
85 render: function() {
86 var frameEl = E('div', { 'id': this.options.id });
87
88 if (this.options.password) {
89 frameEl.classList.add('nowrap');
90 frameEl.appendChild(E('input', {
91 'type': 'password',
92 'style': 'position:absolute; left:-100000px',
93 'aria-hidden': true,
94 'tabindex': -1,
95 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
96 }));
97 }
98
99 frameEl.appendChild(E('input', {
100 'id': this.options.id ? 'widget.' + this.options.id : null,
101 'name': this.options.name,
102 'type': this.options.password ? 'password' : 'text',
103 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
104 'readonly': this.options.readonly ? '' : null,
105 'maxlength': this.options.maxlength,
106 'placeholder': this.options.placeholder,
107 'value': this.value,
108 }));
109
110 if (this.options.password)
111 frameEl.appendChild(E('button', {
112 'class': 'cbi-button cbi-button-neutral',
113 'title': _('Reveal/hide password'),
114 'aria-label': _('Reveal/hide password'),
115 'click': function(ev) {
116 var e = this.previousElementSibling;
117 e.type = (e.type === 'password') ? 'text' : 'password';
118 ev.preventDefault();
119 }
120 }, '∗'));
121
122 return this.bind(frameEl);
123 },
124
125 bind: function(frameEl) {
126 var inputEl = frameEl.childNodes[+!!this.options.password];
127
128 this.node = frameEl;
129
130 this.setUpdateEvents(inputEl, 'keyup', 'blur');
131 this.setChangeEvents(inputEl, 'change');
132
133 L.dom.bindClassInstance(frameEl, this);
134
135 return frameEl;
136 },
137
138 getValue: function() {
139 var inputEl = this.node.childNodes[+!!this.options.password];
140 return inputEl.value;
141 },
142
143 setValue: function(value) {
144 var inputEl = this.node.childNodes[+!!this.options.password];
145 inputEl.value = value;
146 }
147 });
148
149 var UITextarea = UIElement.extend({
150 __init__: function(value, options) {
151 this.value = value;
152 this.options = Object.assign({
153 optional: true,
154 wrap: false,
155 cols: null,
156 rows: null
157 }, options);
158 },
159
160 render: function() {
161 var frameEl = E('div', { 'id': this.options.id }),
162 value = (this.value != null) ? String(this.value) : '';
163
164 frameEl.appendChild(E('textarea', {
165 'id': this.options.id ? 'widget.' + this.options.id : null,
166 'name': this.options.name,
167 'class': 'cbi-input-textarea',
168 'readonly': this.options.readonly ? '' : null,
169 'placeholder': this.options.placeholder,
170 'style': !this.options.cols ? 'width:100%' : null,
171 'cols': this.options.cols,
172 'rows': this.options.rows,
173 'wrap': this.options.wrap ? '' : null
174 }, [ value ]));
175
176 if (this.options.monospace)
177 frameEl.firstElementChild.style.fontFamily = 'monospace';
178
179 return this.bind(frameEl);
180 },
181
182 bind: function(frameEl) {
183 var inputEl = frameEl.firstElementChild;
184
185 this.node = frameEl;
186
187 this.setUpdateEvents(inputEl, 'keyup', 'blur');
188 this.setChangeEvents(inputEl, 'change');
189
190 L.dom.bindClassInstance(frameEl, this);
191
192 return frameEl;
193 },
194
195 getValue: function() {
196 return this.node.firstElementChild.value;
197 },
198
199 setValue: function(value) {
200 this.node.firstElementChild.value = value;
201 }
202 });
203
204 var UICheckbox = UIElement.extend({
205 __init__: function(value, options) {
206 this.value = value;
207 this.options = Object.assign({
208 value_enabled: '1',
209 value_disabled: '0'
210 }, options);
211 },
212
213 render: function() {
214 var frameEl = E('div', {
215 'id': this.options.id,
216 'class': 'cbi-checkbox'
217 });
218
219 if (this.options.hiddenname)
220 frameEl.appendChild(E('input', {
221 'type': 'hidden',
222 'name': this.options.hiddenname,
223 'value': 1
224 }));
225
226 frameEl.appendChild(E('input', {
227 'id': this.options.id ? 'widget.' + this.options.id : null,
228 'name': this.options.name,
229 'type': 'checkbox',
230 'value': this.options.value_enabled,
231 'checked': (this.value == this.options.value_enabled) ? '' : null
232 }));
233
234 return this.bind(frameEl);
235 },
236
237 bind: function(frameEl) {
238 this.node = frameEl;
239
240 this.setUpdateEvents(frameEl.lastElementChild, 'click', 'blur');
241 this.setChangeEvents(frameEl.lastElementChild, 'change');
242
243 L.dom.bindClassInstance(frameEl, this);
244
245 return frameEl;
246 },
247
248 isChecked: function() {
249 return this.node.lastElementChild.checked;
250 },
251
252 getValue: function() {
253 return this.isChecked()
254 ? this.options.value_enabled
255 : this.options.value_disabled;
256 },
257
258 setValue: function(value) {
259 this.node.lastElementChild.checked = (value == this.options.value_enabled);
260 }
261 });
262
263 var UISelect = UIElement.extend({
264 __init__: function(value, choices, options) {
265 if (!L.isObject(choices))
266 choices = {};
267
268 if (!Array.isArray(value))
269 value = (value != null && value != '') ? [ value ] : [];
270
271 if (!options.multiple && value.length > 1)
272 value.length = 1;
273
274 this.values = value;
275 this.choices = choices;
276 this.options = Object.assign({
277 multiple: false,
278 widget: 'select',
279 orientation: 'horizontal'
280 }, options);
281
282 if (this.choices.hasOwnProperty(''))
283 this.options.optional = true;
284 },
285
286 render: function() {
287 var frameEl = E('div', { 'id': this.options.id }),
288 keys = Object.keys(this.choices);
289
290 if (this.options.sort === true)
291 keys.sort();
292 else if (Array.isArray(this.options.sort))
293 keys = this.options.sort;
294
295 if (this.options.widget == 'select') {
296 frameEl.appendChild(E('select', {
297 'id': this.options.id ? 'widget.' + this.options.id : null,
298 'name': this.options.name,
299 'size': this.options.size,
300 'class': 'cbi-input-select',
301 'multiple': this.options.multiple ? '' : null
302 }));
303
304 if (this.options.optional)
305 frameEl.lastChild.appendChild(E('option', {
306 'value': '',
307 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
308 }, this.choices[''] || this.options.placeholder || _('-- Please choose --')));
309
310 for (var i = 0; i < keys.length; i++) {
311 if (keys[i] == null || keys[i] == '')
312 continue;
313
314 frameEl.lastChild.appendChild(E('option', {
315 'value': keys[i],
316 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
317 }, this.choices[keys[i]] || keys[i]));
318 }
319 }
320 else {
321 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
322
323 for (var i = 0; i < keys.length; i++) {
324 frameEl.appendChild(E('label', {}, [
325 E('input', {
326 'id': this.options.id ? 'widget.' + this.options.id : null,
327 'name': this.options.id || this.options.name,
328 'type': this.options.multiple ? 'checkbox' : 'radio',
329 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
330 'value': keys[i],
331 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null
332 }),
333 this.choices[keys[i]] || keys[i]
334 ]));
335
336 if (i + 1 == this.options.size)
337 frameEl.appendChild(brEl);
338 }
339 }
340
341 return this.bind(frameEl);
342 },
343
344 bind: function(frameEl) {
345 this.node = frameEl;
346
347 if (this.options.widget == 'select') {
348 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
349 this.setChangeEvents(frameEl.firstChild, 'change');
350 }
351 else {
352 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
353 for (var i = 0; i < radioEls.length; i++) {
354 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
355 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
356 }
357 }
358
359 L.dom.bindClassInstance(frameEl, this);
360
361 return frameEl;
362 },
363
364 getValue: function() {
365 if (this.options.widget == 'select')
366 return this.node.firstChild.value;
367
368 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
369 for (var i = 0; i < radioEls.length; i++)
370 if (radioEls[i].checked)
371 return radioEls[i].value;
372
373 return null;
374 },
375
376 setValue: function(value) {
377 if (this.options.widget == 'select') {
378 if (value == null)
379 value = '';
380
381 for (var i = 0; i < this.node.firstChild.options.length; i++)
382 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
383
384 return;
385 }
386
387 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
388 for (var i = 0; i < radioEls.length; i++)
389 radioEls[i].checked = (radioEls[i].value == value);
390 }
391 });
392
393 var UIDropdown = UIElement.extend({
394 __init__: function(value, choices, options) {
395 if (typeof(choices) != 'object')
396 choices = {};
397
398 if (!Array.isArray(value))
399 this.values = (value != null && value != '') ? [ value ] : [];
400 else
401 this.values = value;
402
403 this.choices = choices;
404 this.options = Object.assign({
405 sort: true,
406 multiple: Array.isArray(value),
407 optional: true,
408 select_placeholder: _('-- Please choose --'),
409 custom_placeholder: _('-- custom --'),
410 display_items: 3,
411 dropdown_items: -1,
412 create: false,
413 create_query: '.create-item-input',
414 create_template: 'script[type="item-template"]'
415 }, options);
416 },
417
418 render: function() {
419 var sb = E('div', {
420 'id': this.options.id,
421 'class': 'cbi-dropdown',
422 'multiple': this.options.multiple ? '' : null,
423 'optional': this.options.optional ? '' : null,
424 }, E('ul'));
425
426 var keys = Object.keys(this.choices);
427
428 if (this.options.sort === true)
429 keys.sort();
430 else if (Array.isArray(this.options.sort))
431 keys = this.options.sort;
432
433 if (this.options.create)
434 for (var i = 0; i < this.values.length; i++)
435 if (!this.choices.hasOwnProperty(this.values[i]))
436 keys.push(this.values[i]);
437
438 for (var i = 0; i < keys.length; i++)
439 sb.lastElementChild.appendChild(E('li', {
440 'data-value': keys[i],
441 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
442 }, this.choices[keys[i]] || keys[i]));
443
444 if (this.options.create) {
445 var createEl = E('input', {
446 'type': 'text',
447 'class': 'create-item-input',
448 'readonly': this.options.readonly ? '' : null,
449 'maxlength': this.options.maxlength,
450 'placeholder': this.options.custom_placeholder || this.options.placeholder
451 });
452
453 if (this.options.datatype)
454 L.ui.addValidator(createEl, this.options.datatype,
455 true, null, 'blur', 'keyup');
456
457 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
458 }
459
460 if (this.options.create_markup)
461 sb.appendChild(E('script', { type: 'item-template' },
462 this.options.create_markup));
463
464 return this.bind(sb);
465 },
466
467 bind: function(sb) {
468 var o = this.options;
469
470 o.multiple = sb.hasAttribute('multiple');
471 o.optional = sb.hasAttribute('optional');
472 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
473 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
474 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
475 o.create_query = sb.getAttribute('item-create') || o.create_query;
476 o.create_template = sb.getAttribute('item-template') || o.create_template;
477
478 var ul = sb.querySelector('ul'),
479 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
480 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
481 canary = sb.appendChild(E('div')),
482 create = sb.querySelector(this.options.create_query),
483 ndisplay = this.options.display_items,
484 n = 0;
485
486 if (this.options.multiple) {
487 var items = ul.querySelectorAll('li');
488
489 for (var i = 0; i < items.length; i++) {
490 this.transformItem(sb, items[i]);
491
492 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
493 items[i].setAttribute('display', n++);
494 }
495 }
496 else {
497 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
498 var placeholder = E('li', { placeholder: '' },
499 this.options.select_placeholder || this.options.placeholder);
500
501 ul.firstChild
502 ? ul.insertBefore(placeholder, ul.firstChild)
503 : ul.appendChild(placeholder);
504 }
505
506 var items = ul.querySelectorAll('li'),
507 sel = sb.querySelectorAll('[selected]');
508
509 sel.forEach(function(s) {
510 s.removeAttribute('selected');
511 });
512
513 var s = sel[0] || items[0];
514 if (s) {
515 s.setAttribute('selected', '');
516 s.setAttribute('display', n++);
517 }
518
519 ndisplay--;
520 }
521
522 this.saveValues(sb, ul);
523
524 ul.setAttribute('tabindex', -1);
525 sb.setAttribute('tabindex', 0);
526
527 if (ndisplay < 0)
528 sb.setAttribute('more', '')
529 else
530 sb.removeAttribute('more');
531
532 if (ndisplay == this.options.display_items)
533 sb.setAttribute('empty', '')
534 else
535 sb.removeAttribute('empty');
536
537 L.dom.content(more, (ndisplay == this.options.display_items)
538 ? (this.options.select_placeholder || this.options.placeholder) : '···');
539
540
541 sb.addEventListener('click', this.handleClick.bind(this));
542 sb.addEventListener('keydown', this.handleKeydown.bind(this));
543 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
544 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
545
546 if ('ontouchstart' in window) {
547 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
548 window.addEventListener('touchstart', this.closeAllDropdowns);
549 }
550 else {
551 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
552 sb.addEventListener('focus', this.handleFocus.bind(this));
553
554 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
555
556 window.addEventListener('mouseover', this.setFocus);
557 window.addEventListener('click', this.closeAllDropdowns);
558 }
559
560 if (create) {
561 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
562 create.addEventListener('focus', this.handleCreateFocus.bind(this));
563 create.addEventListener('blur', this.handleCreateBlur.bind(this));
564
565 var li = findParent(create, 'li');
566
567 li.setAttribute('unselectable', '');
568 li.addEventListener('click', this.handleCreateClick.bind(this));
569 }
570
571 this.node = sb;
572
573 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
574 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
575
576 L.dom.bindClassInstance(sb, this);
577
578 return sb;
579 },
580
581 openDropdown: function(sb) {
582 var st = window.getComputedStyle(sb, null),
583 ul = sb.querySelector('ul'),
584 li = ul.querySelectorAll('li'),
585 fl = findParent(sb, '.cbi-value-field'),
586 sel = ul.querySelector('[selected]'),
587 rect = sb.getBoundingClientRect(),
588 items = Math.min(this.options.dropdown_items, li.length);
589
590 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
591 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
592 });
593
594 sb.setAttribute('open', '');
595
596 var pv = ul.cloneNode(true);
597 pv.classList.add('preview');
598
599 if (fl)
600 fl.classList.add('cbi-dropdown-open');
601
602 if ('ontouchstart' in window) {
603 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
604 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
605 scrollFrom = window.pageYOffset,
606 scrollTo = scrollFrom + rect.top - vpHeight * 0.5,
607 start = null;
608
609 ul.style.top = sb.offsetHeight + 'px';
610 ul.style.left = -rect.left + 'px';
611 ul.style.right = (rect.right - vpWidth) + 'px';
612 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
613 ul.style.WebkitOverflowScrolling = 'touch';
614
615 var scrollStep = function(timestamp) {
616 if (!start) {
617 start = timestamp;
618 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
619 }
620
621 var duration = Math.max(timestamp - start, 1);
622 if (duration < 100) {
623 document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
624 window.requestAnimationFrame(scrollStep);
625 }
626 else {
627 document.body.scrollTop = scrollTo;
628 }
629 };
630
631 window.requestAnimationFrame(scrollStep);
632 }
633 else {
634 ul.style.maxHeight = '1px';
635 ul.style.top = ul.style.bottom = '';
636
637 window.requestAnimationFrame(function() {
638 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
639 fullHeight = 0,
640 spaceAbove = rect.top,
641 spaceBelow = window.innerHeight - rect.height - rect.top;
642
643 for (var i = 0; i < (items == -1 ? li.length : items); i++)
644 fullHeight += li[i].getBoundingClientRect().height;
645
646 if (fullHeight <= spaceBelow) {
647 ul.style.top = rect.height + 'px';
648 ul.style.maxHeight = spaceBelow + 'px';
649 }
650 else if (fullHeight <= spaceAbove) {
651 ul.style.bottom = rect.height + 'px';
652 ul.style.maxHeight = spaceAbove + 'px';
653 }
654 else if (spaceBelow >= spaceAbove) {
655 ul.style.top = rect.height + 'px';
656 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
657 }
658 else {
659 ul.style.bottom = rect.height + 'px';
660 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
661 }
662
663 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
664 });
665 }
666
667 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
668 for (var i = 0; i < cboxes.length; i++) {
669 cboxes[i].checked = true;
670 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
671 };
672
673 ul.classList.add('dropdown');
674
675 sb.insertBefore(pv, ul.nextElementSibling);
676
677 li.forEach(function(l) {
678 l.setAttribute('tabindex', 0);
679 });
680
681 sb.lastElementChild.setAttribute('tabindex', 0);
682
683 this.setFocus(sb, sel || li[0], true);
684 },
685
686 closeDropdown: function(sb, no_focus) {
687 if (!sb.hasAttribute('open'))
688 return;
689
690 var pv = sb.querySelector('ul.preview'),
691 ul = sb.querySelector('ul.dropdown'),
692 li = ul.querySelectorAll('li'),
693 fl = findParent(sb, '.cbi-value-field');
694
695 li.forEach(function(l) { l.removeAttribute('tabindex'); });
696 sb.lastElementChild.removeAttribute('tabindex');
697
698 sb.removeChild(pv);
699 sb.removeAttribute('open');
700 sb.style.width = sb.style.height = '';
701
702 ul.classList.remove('dropdown');
703 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
704
705 if (fl)
706 fl.classList.remove('cbi-dropdown-open');
707
708 if (!no_focus)
709 this.setFocus(sb, sb);
710
711 this.saveValues(sb, ul);
712 },
713
714 toggleItem: function(sb, li, force_state) {
715 if (li.hasAttribute('unselectable'))
716 return;
717
718 if (this.options.multiple) {
719 var cbox = li.querySelector('input[type="checkbox"]'),
720 items = li.parentNode.querySelectorAll('li'),
721 label = sb.querySelector('ul.preview'),
722 sel = li.parentNode.querySelectorAll('[selected]').length,
723 more = sb.querySelector('.more'),
724 ndisplay = this.options.display_items,
725 n = 0;
726
727 if (li.hasAttribute('selected')) {
728 if (force_state !== true) {
729 if (sel > 1 || this.options.optional) {
730 li.removeAttribute('selected');
731 cbox.checked = cbox.disabled = false;
732 sel--;
733 }
734 else {
735 cbox.disabled = true;
736 }
737 }
738 }
739 else {
740 if (force_state !== false) {
741 li.setAttribute('selected', '');
742 cbox.checked = true;
743 cbox.disabled = false;
744 sel++;
745 }
746 }
747
748 while (label && label.firstElementChild)
749 label.removeChild(label.firstElementChild);
750
751 for (var i = 0; i < items.length; i++) {
752 items[i].removeAttribute('display');
753 if (items[i].hasAttribute('selected')) {
754 if (ndisplay-- > 0) {
755 items[i].setAttribute('display', n++);
756 if (label)
757 label.appendChild(items[i].cloneNode(true));
758 }
759 var c = items[i].querySelector('input[type="checkbox"]');
760 if (c)
761 c.disabled = (sel == 1 && !this.options.optional);
762 }
763 }
764
765 if (ndisplay < 0)
766 sb.setAttribute('more', '');
767 else
768 sb.removeAttribute('more');
769
770 if (ndisplay === this.options.display_items)
771 sb.setAttribute('empty', '');
772 else
773 sb.removeAttribute('empty');
774
775 L.dom.content(more, (ndisplay === this.options.display_items)
776 ? (this.options.select_placeholder || this.options.placeholder) : '···');
777 }
778 else {
779 var sel = li.parentNode.querySelector('[selected]');
780 if (sel) {
781 sel.removeAttribute('display');
782 sel.removeAttribute('selected');
783 }
784
785 li.setAttribute('display', 0);
786 li.setAttribute('selected', '');
787
788 this.closeDropdown(sb, true);
789 }
790
791 this.saveValues(sb, li.parentNode);
792 },
793
794 transformItem: function(sb, li) {
795 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
796 label = E('label');
797
798 while (li.firstChild)
799 label.appendChild(li.firstChild);
800
801 li.appendChild(cbox);
802 li.appendChild(label);
803 },
804
805 saveValues: function(sb, ul) {
806 var sel = ul.querySelectorAll('li[selected]'),
807 div = sb.lastElementChild,
808 name = this.options.name,
809 strval = '',
810 values = [];
811
812 while (div.lastElementChild)
813 div.removeChild(div.lastElementChild);
814
815 sel.forEach(function (s) {
816 if (s.hasAttribute('placeholder'))
817 return;
818
819 var v = {
820 text: s.innerText,
821 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
822 element: s
823 };
824
825 div.appendChild(E('input', {
826 type: 'hidden',
827 name: name,
828 value: v.value
829 }));
830
831 values.push(v);
832
833 strval += strval.length ? ' ' + v.value : v.value;
834 });
835
836 var detail = {
837 instance: this,
838 element: sb
839 };
840
841 if (this.options.multiple)
842 detail.values = values;
843 else
844 detail.value = values.length ? values[0] : null;
845
846 sb.value = strval;
847
848 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
849 bubbles: true,
850 detail: detail
851 }));
852 },
853
854 setValues: function(sb, values) {
855 var ul = sb.querySelector('ul');
856
857 if (this.options.create) {
858 for (var value in values) {
859 this.createItems(sb, value);
860
861 if (!this.options.multiple)
862 break;
863 }
864 }
865
866 if (this.options.multiple) {
867 var lis = ul.querySelectorAll('li[data-value]');
868 for (var i = 0; i < lis.length; i++) {
869 var value = lis[i].getAttribute('data-value');
870 if (values === null || !(value in values))
871 this.toggleItem(sb, lis[i], false);
872 else
873 this.toggleItem(sb, lis[i], true);
874 }
875 }
876 else {
877 var ph = ul.querySelector('li[placeholder]');
878 if (ph)
879 this.toggleItem(sb, ph);
880
881 var lis = ul.querySelectorAll('li[data-value]');
882 for (var i = 0; i < lis.length; i++) {
883 var value = lis[i].getAttribute('data-value');
884 if (values !== null && (value in values))
885 this.toggleItem(sb, lis[i]);
886 }
887 }
888 },
889
890 setFocus: function(sb, elem, scroll) {
891 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
892 return;
893
894 if (sb.target && findParent(sb.target, 'ul.dropdown'))
895 return;
896
897 document.querySelectorAll('.focus').forEach(function(e) {
898 if (!matchesElem(e, 'input')) {
899 e.classList.remove('focus');
900 e.blur();
901 }
902 });
903
904 if (elem) {
905 elem.focus();
906 elem.classList.add('focus');
907
908 if (scroll)
909 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
910 }
911 },
912
913 createItems: function(sb, value) {
914 var sbox = this,
915 val = (value || '').trim(),
916 ul = sb.querySelector('ul');
917
918 if (!sbox.options.multiple)
919 val = val.length ? [ val ] : [];
920 else
921 val = val.length ? val.split(/\s+/) : [];
922
923 val.forEach(function(item) {
924 var new_item = null;
925
926 ul.childNodes.forEach(function(li) {
927 if (li.getAttribute && li.getAttribute('data-value') === item)
928 new_item = li;
929 });
930
931 if (!new_item) {
932 var markup,
933 tpl = sb.querySelector(sbox.options.create_template);
934
935 if (tpl)
936 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
937 else
938 markup = '<li data-value="{{value}}">{{value}}</li>';
939
940 new_item = E(markup.replace(/{{value}}/g, '%h'.format(item)));
941
942 if (sbox.options.multiple) {
943 sbox.transformItem(sb, new_item);
944 }
945 else {
946 var old = ul.querySelector('li[created]');
947 if (old)
948 ul.removeChild(old);
949
950 new_item.setAttribute('created', '');
951 }
952
953 new_item = ul.insertBefore(new_item, ul.lastElementChild);
954 }
955
956 sbox.toggleItem(sb, new_item, true);
957 sbox.setFocus(sb, new_item, true);
958 });
959 },
960
961 closeAllDropdowns: function() {
962 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
963 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
964 });
965 },
966
967 handleClick: function(ev) {
968 var sb = ev.currentTarget;
969
970 if (!sb.hasAttribute('open')) {
971 if (!matchesElem(ev.target, 'input'))
972 this.openDropdown(sb);
973 }
974 else {
975 var li = findParent(ev.target, 'li');
976 if (li && li.parentNode.classList.contains('dropdown'))
977 this.toggleItem(sb, li);
978 else if (li && li.parentNode.classList.contains('preview'))
979 this.closeDropdown(sb);
980 else if (matchesElem(ev.target, 'span.open, span.more'))
981 this.closeDropdown(sb);
982 }
983
984 ev.preventDefault();
985 ev.stopPropagation();
986 },
987
988 handleKeydown: function(ev) {
989 var sb = ev.currentTarget;
990
991 if (matchesElem(ev.target, 'input'))
992 return;
993
994 if (!sb.hasAttribute('open')) {
995 switch (ev.keyCode) {
996 case 37:
997 case 38:
998 case 39:
999 case 40:
1000 this.openDropdown(sb);
1001 ev.preventDefault();
1002 }
1003 }
1004 else {
1005 var active = findParent(document.activeElement, 'li');
1006
1007 switch (ev.keyCode) {
1008 case 27:
1009 this.closeDropdown(sb);
1010 break;
1011
1012 case 13:
1013 if (active) {
1014 if (!active.hasAttribute('selected'))
1015 this.toggleItem(sb, active);
1016 this.closeDropdown(sb);
1017 ev.preventDefault();
1018 }
1019 break;
1020
1021 case 32:
1022 if (active) {
1023 this.toggleItem(sb, active);
1024 ev.preventDefault();
1025 }
1026 break;
1027
1028 case 38:
1029 if (active && active.previousElementSibling) {
1030 this.setFocus(sb, active.previousElementSibling);
1031 ev.preventDefault();
1032 }
1033 break;
1034
1035 case 40:
1036 if (active && active.nextElementSibling) {
1037 this.setFocus(sb, active.nextElementSibling);
1038 ev.preventDefault();
1039 }
1040 break;
1041 }
1042 }
1043 },
1044
1045 handleDropdownClose: function(ev) {
1046 var sb = ev.currentTarget;
1047
1048 this.closeDropdown(sb, true);
1049 },
1050
1051 handleDropdownSelect: function(ev) {
1052 var sb = ev.currentTarget,
1053 li = findParent(ev.target, 'li');
1054
1055 if (!li)
1056 return;
1057
1058 this.toggleItem(sb, li);
1059 this.closeDropdown(sb, true);
1060 },
1061
1062 handleMouseover: function(ev) {
1063 var sb = ev.currentTarget;
1064
1065 if (!sb.hasAttribute('open'))
1066 return;
1067
1068 var li = findParent(ev.target, 'li');
1069
1070 if (li && li.parentNode.classList.contains('dropdown'))
1071 this.setFocus(sb, li);
1072 },
1073
1074 handleFocus: function(ev) {
1075 var sb = ev.currentTarget;
1076
1077 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1078 if (s !== sb || sb.hasAttribute('open'))
1079 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1080 });
1081 },
1082
1083 handleCanaryFocus: function(ev) {
1084 this.closeDropdown(ev.currentTarget.parentNode);
1085 },
1086
1087 handleCreateKeydown: function(ev) {
1088 var input = ev.currentTarget,
1089 sb = findParent(input, '.cbi-dropdown');
1090
1091 switch (ev.keyCode) {
1092 case 13:
1093 ev.preventDefault();
1094
1095 if (input.classList.contains('cbi-input-invalid'))
1096 return;
1097
1098 this.createItems(sb, input.value);
1099 input.value = '';
1100 input.blur();
1101 break;
1102 }
1103 },
1104
1105 handleCreateFocus: function(ev) {
1106 var input = ev.currentTarget,
1107 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1108 sb = findParent(input, '.cbi-dropdown');
1109
1110 if (cbox)
1111 cbox.checked = true;
1112
1113 sb.setAttribute('locked-in', '');
1114 },
1115
1116 handleCreateBlur: function(ev) {
1117 var input = ev.currentTarget,
1118 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1119 sb = findParent(input, '.cbi-dropdown');
1120
1121 if (cbox)
1122 cbox.checked = false;
1123
1124 sb.removeAttribute('locked-in');
1125 },
1126
1127 handleCreateClick: function(ev) {
1128 ev.currentTarget.querySelector(this.options.create_query).focus();
1129 },
1130
1131 setValue: function(values) {
1132 if (this.options.multiple) {
1133 if (!Array.isArray(values))
1134 values = (values != null && values != '') ? [ values ] : [];
1135
1136 var v = {};
1137
1138 for (var i = 0; i < values.length; i++)
1139 v[values[i]] = true;
1140
1141 this.setValues(this.node, v);
1142 }
1143 else {
1144 var v = {};
1145
1146 if (values != null) {
1147 if (Array.isArray(values))
1148 v[values[0]] = true;
1149 else
1150 v[values] = true;
1151 }
1152
1153 this.setValues(this.node, v);
1154 }
1155 },
1156
1157 getValue: function() {
1158 var div = this.node.lastElementChild,
1159 h = div.querySelectorAll('input[type="hidden"]'),
1160 v = [];
1161
1162 for (var i = 0; i < h.length; i++)
1163 v.push(h[i].value);
1164
1165 return this.options.multiple ? v : v[0];
1166 }
1167 });
1168
1169 var UICombobox = UIDropdown.extend({
1170 __init__: function(value, choices, options) {
1171 this.super('__init__', [ value, choices, Object.assign({
1172 select_placeholder: _('-- Please choose --'),
1173 custom_placeholder: _('-- custom --'),
1174 dropdown_items: -1,
1175 sort: true
1176 }, options, {
1177 multiple: false,
1178 create: true,
1179 optional: true
1180 }) ]);
1181 }
1182 });
1183
1184 var UIDynamicList = UIElement.extend({
1185 __init__: function(values, choices, options) {
1186 if (!Array.isArray(values))
1187 values = (values != null && values != '') ? [ values ] : [];
1188
1189 if (typeof(choices) != 'object')
1190 choices = null;
1191
1192 this.values = values;
1193 this.choices = choices;
1194 this.options = Object.assign({}, options, {
1195 multiple: false,
1196 optional: true
1197 });
1198 },
1199
1200 render: function() {
1201 var dl = E('div', {
1202 'id': this.options.id,
1203 'class': 'cbi-dynlist'
1204 }, E('div', { 'class': 'add-item' }));
1205
1206 if (this.choices) {
1207 var cbox = new UICombobox(null, this.choices, this.options);
1208 dl.lastElementChild.appendChild(cbox.render());
1209 }
1210 else {
1211 var inputEl = E('input', {
1212 'id': this.options.id ? 'widget.' + this.options.id : null,
1213 'type': 'text',
1214 'class': 'cbi-input-text',
1215 'placeholder': this.options.placeholder
1216 });
1217
1218 dl.lastElementChild.appendChild(inputEl);
1219 dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
1220
1221 if (this.options.datatype)
1222 L.ui.addValidator(inputEl, this.options.datatype,
1223 true, null, 'blur', 'keyup');
1224 }
1225
1226 for (var i = 0; i < this.values.length; i++)
1227 this.addItem(dl, this.values[i],
1228 this.choices ? this.choices[this.values[i]] : null);
1229
1230 return this.bind(dl);
1231 },
1232
1233 bind: function(dl) {
1234 dl.addEventListener('click', L.bind(this.handleClick, this));
1235 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
1236 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
1237
1238 this.node = dl;
1239
1240 this.setUpdateEvents(dl, 'cbi-dynlist-change');
1241 this.setChangeEvents(dl, 'cbi-dynlist-change');
1242
1243 L.dom.bindClassInstance(dl, this);
1244
1245 return dl;
1246 },
1247
1248 addItem: function(dl, value, text, flash) {
1249 var exists = false,
1250 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
1251 E('span', {}, text || value),
1252 E('input', {
1253 'type': 'hidden',
1254 'name': this.options.name,
1255 'value': value })]);
1256
1257 dl.querySelectorAll('.item').forEach(function(item) {
1258 if (exists)
1259 return;
1260
1261 var hidden = item.querySelector('input[type="hidden"]');
1262
1263 if (hidden && hidden.parentNode !== item)
1264 hidden = null;
1265
1266 if (hidden && hidden.value === value)
1267 exists = true;
1268 });
1269
1270 if (!exists) {
1271 var ai = dl.querySelector('.add-item');
1272 ai.parentNode.insertBefore(new_item, ai);
1273 }
1274
1275 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1276 bubbles: true,
1277 detail: {
1278 instance: this,
1279 element: dl,
1280 value: value,
1281 add: true
1282 }
1283 }));
1284 },
1285
1286 removeItem: function(dl, item) {
1287 var value = item.querySelector('input[type="hidden"]').value;
1288 var sb = dl.querySelector('.cbi-dropdown');
1289 if (sb)
1290 sb.querySelectorAll('ul > li').forEach(function(li) {
1291 if (li.getAttribute('data-value') === value) {
1292 if (li.hasAttribute('dynlistcustom'))
1293 li.parentNode.removeChild(li);
1294 else
1295 li.removeAttribute('unselectable');
1296 }
1297 });
1298
1299 item.parentNode.removeChild(item);
1300
1301 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1302 bubbles: true,
1303 detail: {
1304 instance: this,
1305 element: dl,
1306 value: value,
1307 remove: true
1308 }
1309 }));
1310 },
1311
1312 handleClick: function(ev) {
1313 var dl = ev.currentTarget,
1314 item = findParent(ev.target, '.item');
1315
1316 if (item) {
1317 this.removeItem(dl, item);
1318 }
1319 else if (matchesElem(ev.target, '.cbi-button-add')) {
1320 var input = ev.target.previousElementSibling;
1321 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
1322 this.addItem(dl, input.value, null, true);
1323 input.value = '';
1324 }
1325 }
1326 },
1327
1328 handleDropdownChange: function(ev) {
1329 var dl = ev.currentTarget,
1330 sbIn = ev.detail.instance,
1331 sbEl = ev.detail.element,
1332 sbVal = ev.detail.value;
1333
1334 if (sbVal === null)
1335 return;
1336
1337 sbIn.setValues(sbEl, null);
1338 sbVal.element.setAttribute('unselectable', '');
1339
1340 if (sbVal.element.hasAttribute('created')) {
1341 sbVal.element.removeAttribute('created');
1342 sbVal.element.setAttribute('dynlistcustom', '');
1343 }
1344
1345 this.addItem(dl, sbVal.value, sbVal.text, true);
1346 },
1347
1348 handleKeydown: function(ev) {
1349 var dl = ev.currentTarget,
1350 item = findParent(ev.target, '.item');
1351
1352 if (item) {
1353 switch (ev.keyCode) {
1354 case 8: /* backspace */
1355 if (item.previousElementSibling)
1356 item.previousElementSibling.focus();
1357
1358 this.removeItem(dl, item);
1359 break;
1360
1361 case 46: /* delete */
1362 if (item.nextElementSibling) {
1363 if (item.nextElementSibling.classList.contains('item'))
1364 item.nextElementSibling.focus();
1365 else
1366 item.nextElementSibling.firstElementChild.focus();
1367 }
1368
1369 this.removeItem(dl, item);
1370 break;
1371 }
1372 }
1373 else if (matchesElem(ev.target, '.cbi-input-text')) {
1374 switch (ev.keyCode) {
1375 case 13: /* enter */
1376 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
1377 this.addItem(dl, ev.target.value, null, true);
1378 ev.target.value = '';
1379 ev.target.blur();
1380 ev.target.focus();
1381 }
1382
1383 ev.preventDefault();
1384 break;
1385 }
1386 }
1387 },
1388
1389 getValue: function() {
1390 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
1391 input = this.node.querySelector('.add-item > input[type="text"]'),
1392 v = [];
1393
1394 for (var i = 0; i < items.length; i++)
1395 v.push(items[i].value);
1396
1397 if (input && input.value != null && input.value.match(/\S/) &&
1398 input.classList.contains('cbi-input-invalid') == false &&
1399 v.filter(function(s) { return s == input.value }).length == 0)
1400 v.push(input.value);
1401
1402 return v;
1403 },
1404
1405 setValue: function(values) {
1406 if (!Array.isArray(values))
1407 values = (values != null && values != '') ? [ values ] : [];
1408
1409 var items = this.node.querySelectorAll('.item');
1410
1411 for (var i = 0; i < items.length; i++)
1412 if (items[i].parentNode === this.node)
1413 this.removeItem(this.node, items[i]);
1414
1415 for (var i = 0; i < values.length; i++)
1416 this.addItem(this.node, values[i],
1417 this.choices ? this.choices[values[i]] : null);
1418 }
1419 });
1420
1421 var UIHiddenfield = UIElement.extend({
1422 __init__: function(value, options) {
1423 this.value = value;
1424 this.options = Object.assign({
1425
1426 }, options);
1427 },
1428
1429 render: function() {
1430 var hiddenEl = E('input', {
1431 'id': this.options.id,
1432 'type': 'hidden',
1433 'value': this.value
1434 });
1435
1436 return this.bind(hiddenEl);
1437 },
1438
1439 bind: function(hiddenEl) {
1440 this.node = hiddenEl;
1441
1442 L.dom.bindClassInstance(hiddenEl, this);
1443
1444 return hiddenEl;
1445 },
1446
1447 getValue: function() {
1448 return this.node.value;
1449 },
1450
1451 setValue: function(value) {
1452 this.node.value = value;
1453 }
1454 });
1455
1456
1457 return L.Class.extend({
1458 __init__: function() {
1459 modalDiv = document.body.appendChild(
1460 L.dom.create('div', { id: 'modal_overlay' },
1461 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
1462
1463 tooltipDiv = document.body.appendChild(
1464 L.dom.create('div', { class: 'cbi-tooltip' }));
1465
1466 /* setup old aliases */
1467 L.showModal = this.showModal;
1468 L.hideModal = this.hideModal;
1469 L.showTooltip = this.showTooltip;
1470 L.hideTooltip = this.hideTooltip;
1471 L.itemlist = this.itemlist;
1472
1473 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
1474 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
1475 document.addEventListener('focus', this.showTooltip.bind(this), true);
1476 document.addEventListener('blur', this.hideTooltip.bind(this), true);
1477
1478 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
1479 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
1480 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
1481 },
1482
1483 /* Modal dialog */
1484 showModal: function(title, children /* , ... */) {
1485 var dlg = modalDiv.firstElementChild;
1486
1487 dlg.setAttribute('class', 'modal');
1488
1489 for (var i = 2; i < arguments.length; i++)
1490 dlg.classList.add(arguments[i]);
1491
1492 L.dom.content(dlg, L.dom.create('h4', {}, title));
1493 L.dom.append(dlg, children);
1494
1495 document.body.classList.add('modal-overlay-active');
1496
1497 return dlg;
1498 },
1499
1500 hideModal: function() {
1501 document.body.classList.remove('modal-overlay-active');
1502 },
1503
1504 /* Tooltip */
1505 showTooltip: function(ev) {
1506 var target = findParent(ev.target, '[data-tooltip]');
1507
1508 if (!target)
1509 return;
1510
1511 if (tooltipTimeout !== null) {
1512 window.clearTimeout(tooltipTimeout);
1513 tooltipTimeout = null;
1514 }
1515
1516 var rect = target.getBoundingClientRect(),
1517 x = rect.left + window.pageXOffset,
1518 y = rect.top + rect.height + window.pageYOffset;
1519
1520 tooltipDiv.className = 'cbi-tooltip';
1521 tooltipDiv.innerHTML = '▲ ';
1522 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
1523
1524 if (target.hasAttribute('data-tooltip-style'))
1525 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
1526
1527 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
1528 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
1529 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
1530 }
1531
1532 tooltipDiv.style.top = y + 'px';
1533 tooltipDiv.style.left = x + 'px';
1534 tooltipDiv.style.opacity = 1;
1535
1536 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
1537 bubbles: true,
1538 detail: { target: target }
1539 }));
1540 },
1541
1542 hideTooltip: function(ev) {
1543 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
1544 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
1545 return;
1546
1547 if (tooltipTimeout !== null) {
1548 window.clearTimeout(tooltipTimeout);
1549 tooltipTimeout = null;
1550 }
1551
1552 tooltipDiv.style.opacity = 0;
1553 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
1554
1555 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
1556 },
1557
1558 /* Widget helper */
1559 itemlist: function(node, items, separators) {
1560 var children = [];
1561
1562 if (!Array.isArray(separators))
1563 separators = [ separators || E('br') ];
1564
1565 for (var i = 0; i < items.length; i += 2) {
1566 if (items[i+1] !== null && items[i+1] !== undefined) {
1567 var sep = separators[(i/2) % separators.length],
1568 cld = [];
1569
1570 children.push(E('span', { class: 'nowrap' }, [
1571 items[i] ? E('strong', items[i] + ': ') : '',
1572 items[i+1]
1573 ]));
1574
1575 if ((i+2) < items.length)
1576 children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
1577 }
1578 }
1579
1580 L.dom.content(node, children);
1581
1582 return node;
1583 },
1584
1585 /* Tabs */
1586 tabs: L.Class.singleton({
1587 init: function() {
1588 var groups = [], prevGroup = null, currGroup = null;
1589
1590 document.querySelectorAll('[data-tab]').forEach(function(tab) {
1591 var parent = tab.parentNode;
1592
1593 if (!parent.hasAttribute('data-tab-group'))
1594 parent.setAttribute('data-tab-group', groups.length);
1595
1596 currGroup = +parent.getAttribute('data-tab-group');
1597
1598 if (currGroup !== prevGroup) {
1599 prevGroup = currGroup;
1600
1601 if (!groups[currGroup])
1602 groups[currGroup] = [];
1603 }
1604
1605 groups[currGroup].push(tab);
1606 });
1607
1608 for (var i = 0; i < groups.length; i++)
1609 this.initTabGroup(groups[i]);
1610
1611 document.addEventListener('dependency-update', this.updateTabs.bind(this));
1612
1613 this.updateTabs();
1614 },
1615
1616 initTabGroup: function(panes) {
1617 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
1618 return;
1619
1620 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
1621 group = panes[0].parentNode,
1622 groupId = +group.getAttribute('data-tab-group'),
1623 selected = null;
1624
1625 for (var i = 0, pane; pane = panes[i]; i++) {
1626 var name = pane.getAttribute('data-tab'),
1627 title = pane.getAttribute('data-tab-title'),
1628 active = pane.getAttribute('data-tab-active') === 'true';
1629
1630 menu.appendChild(E('li', {
1631 'style': L.dom.isEmpty(pane) ? 'display:none' : null,
1632 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
1633 'data-tab': name
1634 }, E('a', {
1635 'href': '#',
1636 'click': this.switchTab.bind(this)
1637 }, title)));
1638
1639 if (active)
1640 selected = i;
1641 }
1642
1643 group.parentNode.insertBefore(menu, group);
1644
1645 if (selected === null) {
1646 selected = this.getActiveTabId(panes[0]);
1647
1648 if (selected < 0 || selected >= panes.length || L.dom.isEmpty(panes[selected])) {
1649 for (var i = 0; i < panes.length; i++) {
1650 if (!L.dom.isEmpty(panes[i])) {
1651 selected = i;
1652 break;
1653 }
1654 }
1655 }
1656
1657 menu.childNodes[selected].classList.add('cbi-tab');
1658 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
1659 panes[selected].setAttribute('data-tab-active', 'true');
1660
1661 this.setActiveTabId(panes[selected], selected);
1662 }
1663 },
1664
1665 getPathForPane: function(pane) {
1666 var path = [], node = null;
1667
1668 for (node = pane ? pane.parentNode : null;
1669 node != null && node.hasAttribute != null;
1670 node = node.parentNode)
1671 {
1672 if (node.hasAttribute('data-tab'))
1673 path.unshift(node.getAttribute('data-tab'));
1674 else if (node.hasAttribute('data-section-id'))
1675 path.unshift(node.getAttribute('data-section-id'));
1676 }
1677
1678 return path.join('/');
1679 },
1680
1681 getActiveTabState: function() {
1682 var page = document.body.getAttribute('data-page');
1683
1684 try {
1685 var val = JSON.parse(window.sessionStorage.getItem('tab'));
1686 if (val.page === page && L.isObject(val.paths))
1687 return val;
1688 }
1689 catch(e) {}
1690
1691 window.sessionStorage.removeItem('tab');
1692 return { page: page, paths: {} };
1693 },
1694
1695 getActiveTabId: function(pane) {
1696 var path = this.getPathForPane(pane);
1697 return +this.getActiveTabState().paths[path] || 0;
1698 },
1699
1700 setActiveTabId: function(pane, tabIndex) {
1701 var path = this.getPathForPane(pane);
1702
1703 try {
1704 var state = this.getActiveTabState();
1705 state.paths[path] = tabIndex;
1706
1707 window.sessionStorage.setItem('tab', JSON.stringify(state));
1708 }
1709 catch (e) { return false; }
1710
1711 return true;
1712 },
1713
1714 updateTabs: function(ev, root) {
1715 (root || document).querySelectorAll('[data-tab-title]').forEach(function(pane) {
1716 var menu = pane.parentNode.previousElementSibling,
1717 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
1718 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
1719
1720 if (!menu || !tab)
1721 return;
1722
1723 if (L.dom.isEmpty(pane)) {
1724 tab.style.display = 'none';
1725 tab.classList.remove('flash');
1726 }
1727 else if (tab.style.display === 'none') {
1728 tab.style.display = '';
1729 requestAnimationFrame(function() { tab.classList.add('flash') });
1730 }
1731
1732 if (n_errors) {
1733 tab.setAttribute('data-errors', n_errors);
1734 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
1735 tab.setAttribute('data-tooltip-style', 'error');
1736 }
1737 else {
1738 tab.removeAttribute('data-errors');
1739 tab.removeAttribute('data-tooltip');
1740 }
1741 });
1742 },
1743
1744 switchTab: function(ev) {
1745 var tab = ev.target.parentNode,
1746 name = tab.getAttribute('data-tab'),
1747 menu = tab.parentNode,
1748 group = menu.nextElementSibling,
1749 groupId = +group.getAttribute('data-tab-group'),
1750 index = 0;
1751
1752 ev.preventDefault();
1753
1754 if (!tab.classList.contains('cbi-tab-disabled'))
1755 return;
1756
1757 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
1758 tab.classList.remove('cbi-tab');
1759 tab.classList.remove('cbi-tab-disabled');
1760 tab.classList.add(
1761 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
1762 });
1763
1764 group.childNodes.forEach(function(pane) {
1765 if (L.dom.matches(pane, '[data-tab]')) {
1766 if (pane.getAttribute('data-tab') === name) {
1767 pane.setAttribute('data-tab-active', 'true');
1768 L.ui.tabs.setActiveTabId(pane, index);
1769 }
1770 else {
1771 pane.setAttribute('data-tab-active', 'false');
1772 }
1773
1774 index++;
1775 }
1776 });
1777 }
1778 }),
1779
1780 /* UCI Changes */
1781 changes: L.Class.singleton({
1782 init: function() {
1783 if (!L.env.sessionid)
1784 return;
1785
1786 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
1787 },
1788
1789 setIndicator: function(n) {
1790 var i = document.querySelector('.uci_change_indicator');
1791 if (i == null) {
1792 var poll = document.getElementById('xhr_poll_status');
1793 i = poll.parentNode.insertBefore(E('a', {
1794 'href': '#',
1795 'class': 'uci_change_indicator label notice',
1796 'click': L.bind(this.displayChanges, this)
1797 }), poll);
1798 }
1799
1800 if (n > 0) {
1801 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
1802 i.classList.add('flash');
1803 i.style.display = '';
1804 }
1805 else {
1806 i.classList.remove('flash');
1807 i.style.display = 'none';
1808 }
1809 },
1810
1811 renderChangeIndicator: function(changes) {
1812 var n_changes = 0;
1813
1814 for (var config in changes)
1815 if (changes.hasOwnProperty(config))
1816 n_changes += changes[config].length;
1817
1818 this.changes = changes;
1819 this.setIndicator(n_changes);
1820 },
1821
1822 changeTemplates: {
1823 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
1824 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
1825 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
1826 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
1827 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
1828 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
1829 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
1830 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
1831 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
1832 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
1833 },
1834
1835 displayChanges: function() {
1836 var list = E('div', { 'class': 'uci-change-list' }),
1837 dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
1838 E('div', { 'class': 'cbi-section' }, [
1839 E('strong', _('Legend:')),
1840 E('div', { 'class': 'uci-change-legend' }, [
1841 E('div', { 'class': 'uci-change-legend-label' }, [
1842 E('ins', '&#160;'), ' ', _('Section added') ]),
1843 E('div', { 'class': 'uci-change-legend-label' }, [
1844 E('del', '&#160;'), ' ', _('Section removed') ]),
1845 E('div', { 'class': 'uci-change-legend-label' }, [
1846 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
1847 E('div', { 'class': 'uci-change-legend-label' }, [
1848 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
1849 E('br'), list,
1850 E('div', { 'class': 'right' }, [
1851 E('input', {
1852 'type': 'button',
1853 'class': 'btn',
1854 'click': L.ui.hideModal,
1855 'value': _('Dismiss')
1856 }), ' ',
1857 E('input', {
1858 'type': 'button',
1859 'class': 'cbi-button cbi-button-positive important',
1860 'click': L.bind(this.apply, this, true),
1861 'value': _('Save & Apply')
1862 }), ' ',
1863 E('input', {
1864 'type': 'button',
1865 'class': 'cbi-button cbi-button-reset',
1866 'click': L.bind(this.revert, this),
1867 'value': _('Revert')
1868 })])])
1869 ]);
1870
1871 for (var config in this.changes) {
1872 if (!this.changes.hasOwnProperty(config))
1873 continue;
1874
1875 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
1876
1877 for (var i = 0, added = null; i < this.changes[config].length; i++) {
1878 var chg = this.changes[config][i],
1879 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
1880
1881 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
1882 switch (+m1) {
1883 case 0:
1884 return config;
1885
1886 case 2:
1887 if (added != null && chg[1] == added[0])
1888 return '@' + added[1] + '[-1]';
1889 else
1890 return chg[1];
1891
1892 case 4:
1893 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
1894
1895 default:
1896 return chg[m1-1];
1897 }
1898 })));
1899
1900 if (chg[0] == 'add')
1901 added = [ chg[1], chg[2] ];
1902 }
1903 }
1904
1905 list.appendChild(E('br'));
1906 dlg.classList.add('uci-dialog');
1907 },
1908
1909 displayStatus: function(type, content) {
1910 if (type) {
1911 var message = L.ui.showModal('', '');
1912
1913 message.classList.add('alert-message');
1914 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
1915
1916 if (content)
1917 L.dom.content(message, content);
1918
1919 if (!this.was_polling) {
1920 this.was_polling = L.Request.poll.active();
1921 L.Request.poll.stop();
1922 }
1923 }
1924 else {
1925 L.ui.hideModal();
1926
1927 if (this.was_polling)
1928 L.Request.poll.start();
1929 }
1930 },
1931
1932 rollback: function(checked) {
1933 if (checked) {
1934 this.displayStatus('warning spinning',
1935 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
1936 .format(L.env.apply_rollback)));
1937
1938 var call = function(r, data, duration) {
1939 if (r.status === 204) {
1940 L.ui.changes.displayStatus('warning', [
1941 E('h4', _('Configuration has been rolled back!')),
1942 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)),
1943 E('div', { 'class': 'right' }, [
1944 E('input', {
1945 'type': 'button',
1946 'class': 'btn',
1947 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false),
1948 'value': _('Dismiss')
1949 }), ' ',
1950 E('input', {
1951 'type': 'button',
1952 'class': 'btn cbi-button-action important',
1953 'click': L.bind(L.ui.changes.revert, L.ui.changes),
1954 'value': _('Revert changes')
1955 }), ' ',
1956 E('input', {
1957 'type': 'button',
1958 'class': 'btn cbi-button-negative important',
1959 'click': L.bind(L.ui.changes.apply, L.ui.changes, false),
1960 'value': _('Apply unchecked')
1961 })
1962 ])
1963 ]);
1964
1965 return;
1966 }
1967
1968 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
1969 window.setTimeout(function() {
1970 L.Request.request(L.url('admin/uci/confirm'), {
1971 method: 'post',
1972 timeout: L.env.apply_timeout * 1000,
1973 query: { sid: L.env.sessionid, token: L.env.token }
1974 }).then(call);
1975 }, delay);
1976 };
1977
1978 call({ status: 0 });
1979 }
1980 else {
1981 this.displayStatus('warning', [
1982 E('h4', _('Device unreachable!')),
1983 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.'))
1984 ]);
1985 }
1986 },
1987
1988 confirm: function(checked, deadline, override_token) {
1989 var tt;
1990 var ts = Date.now();
1991
1992 this.displayStatus('notice');
1993
1994 if (override_token)
1995 this.confirm_auth = { token: override_token };
1996
1997 var call = function(r, data, duration) {
1998 if (Date.now() >= deadline) {
1999 window.clearTimeout(tt);
2000 L.ui.changes.rollback(checked);
2001 return;
2002 }
2003 else if (r && (r.status === 200 || r.status === 204)) {
2004 document.dispatchEvent(new CustomEvent('uci-applied'));
2005
2006 L.ui.changes.setIndicator(0);
2007 L.ui.changes.displayStatus('notice',
2008 E('p', _('Configuration has been applied.')));
2009
2010 window.clearTimeout(tt);
2011 window.setTimeout(function() {
2012 //L.ui.changes.displayStatus(false);
2013 window.location = window.location.href.split('#')[0];
2014 }, L.env.apply_display * 1000);
2015
2016 return;
2017 }
2018
2019 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2020 window.setTimeout(function() {
2021 L.Request.request(L.url('admin/uci/confirm'), {
2022 method: 'post',
2023 timeout: L.env.apply_timeout * 1000,
2024 query: L.ui.changes.confirm_auth
2025 }).then(call, call);
2026 }, delay);
2027 };
2028
2029 var tick = function() {
2030 var now = Date.now();
2031
2032 L.ui.changes.displayStatus('notice spinning',
2033 E('p', _('Waiting for configuration to get applied… %ds')
2034 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
2035
2036 if (now >= deadline)
2037 return;
2038
2039 tt = window.setTimeout(tick, 1000 - (now - ts));
2040 ts = now;
2041 };
2042
2043 tick();
2044
2045 /* wait a few seconds for the settings to become effective */
2046 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
2047 },
2048
2049 apply: function(checked) {
2050 this.displayStatus('notice spinning',
2051 E('p', _('Starting configuration apply…')));
2052
2053 L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
2054 method: 'post',
2055 query: { sid: L.env.sessionid, token: L.env.token }
2056 }).then(function(r) {
2057 if (r.status === (checked ? 200 : 204)) {
2058 var tok = null; try { tok = r.json(); } catch(e) {}
2059 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
2060 L.ui.changes.confirm_auth = tok;
2061
2062 L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
2063 }
2064 else if (checked && r.status === 204) {
2065 L.ui.changes.displayStatus('notice',
2066 E('p', _('There are no changes to apply')));
2067
2068 window.setTimeout(function() {
2069 L.ui.changes.displayStatus(false);
2070 }, L.env.apply_display * 1000);
2071 }
2072 else {
2073 L.ui.changes.displayStatus('warning',
2074 E('p', _('Apply request failed with status <code>%h</code>')
2075 .format(r.responseText || r.statusText || r.status)));
2076
2077 window.setTimeout(function() {
2078 L.ui.changes.displayStatus(false);
2079 }, L.env.apply_display * 1000);
2080 }
2081 });
2082 },
2083
2084 revert: function() {
2085 this.displayStatus('notice spinning',
2086 E('p', _('Reverting configuration…')));
2087
2088 L.Request.request(L.url('admin/uci/revert'), {
2089 method: 'post',
2090 query: { sid: L.env.sessionid, token: L.env.token }
2091 }).then(function(r) {
2092 if (r.status === 200) {
2093 document.dispatchEvent(new CustomEvent('uci-reverted'));
2094
2095 L.ui.changes.setIndicator(0);
2096 L.ui.changes.displayStatus('notice',
2097 E('p', _('Changes have been reverted.')));
2098
2099 window.setTimeout(function() {
2100 //L.ui.changes.displayStatus(false);
2101 window.location = window.location.href.split('#')[0];
2102 }, L.env.apply_display * 1000);
2103 }
2104 else {
2105 L.ui.changes.displayStatus('warning',
2106 E('p', _('Revert request failed with status <code>%h</code>')
2107 .format(r.statusText || r.status)));
2108
2109 window.setTimeout(function() {
2110 L.ui.changes.displayStatus(false);
2111 }, L.env.apply_display * 1000);
2112 }
2113 });
2114 }
2115 }),
2116
2117 addValidator: function(field, type, optional, vfunc /*, ... */) {
2118 if (type == null)
2119 return;
2120
2121 var events = this.varargs(arguments, 3);
2122 if (events.length == 0)
2123 events.push('blur', 'keyup');
2124
2125 try {
2126 var cbiValidator = L.validation.create(field, type, optional, vfunc),
2127 validatorFn = cbiValidator.validate.bind(cbiValidator);
2128
2129 for (var i = 0; i < events.length; i++)
2130 field.addEventListener(events[i], validatorFn);
2131
2132 validatorFn();
2133
2134 return validatorFn;
2135 }
2136 catch (e) { }
2137 },
2138
2139 createHandlerFn: function(ctx, fn /*, ... */) {
2140 if (typeof(fn) == 'string')
2141 fn = ctx[fn];
2142
2143 if (typeof(fn) != 'function')
2144 return null;
2145
2146 return Function.prototype.bind.apply(function() {
2147 var t = arguments[arguments.length - 1].target;
2148
2149 t.classList.add('spinning');
2150 t.disabled = true;
2151
2152 if (t.blur)
2153 t.blur();
2154
2155 Promise.resolve(fn.apply(ctx, arguments)).then(function() {
2156 t.classList.remove('spinning');
2157 t.disabled = false;
2158 });
2159 }, this.varargs(arguments, 2, ctx));
2160 },
2161
2162 /* Widgets */
2163 Textfield: UITextfield,
2164 Textarea: UITextarea,
2165 Checkbox: UICheckbox,
2166 Select: UISelect,
2167 Dropdown: UIDropdown,
2168 DynamicList: UIDynamicList,
2169 Combobox: UICombobox,
2170 Hiddenfield: UIHiddenfield
2171 });