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