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