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