5914b5ee690beb85c2aef50cad63f7c034bec410
[oweals/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2
3 var modalDiv = null,
4     tooltipDiv = null,
5     tooltipTimeout = null;
6
7 var UIElement = L.Class.extend({
8         getValue: function() {
9                 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
10                         return this.node.value;
11
12                 return null;
13         },
14
15         setValue: function(value) {
16                 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
17                         this.node.value = value;
18         },
19
20         isValid: function() {
21                 return true;
22         },
23
24         registerEvents: function(targetNode, synevent, events) {
25                 var dispatchFn = L.bind(function(ev) {
26                         this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
27                 }, this);
28
29                 for (var i = 0; i < events.length; i++)
30                         targetNode.addEventListener(events[i], dispatchFn);
31         },
32
33         setUpdateEvents: function(targetNode /*, ... */) {
34                 this.registerEvents(targetNode, 'widget-update', this.varargs(arguments, 1));
35         },
36
37         setChangeEvents: function(targetNode /*, ... */) {
38                 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
39         }
40 });
41
42 var UIDropdown = UIElement.extend({
43         __init__: function(value, choices, options) {
44                 if (typeof(choices) != 'object')
45                         choices = {};
46
47                 if (!Array.isArray(value))
48                         this.values = (value != null) ? [ value ] : [];
49                 else
50                         this.values = value;
51
52                 this.choices = choices;
53                 this.options = Object.assign({
54                         sort:               true,
55                         multi:              Array.isArray(value),
56                         optional:           true,
57                         select_placeholder: _('-- Please choose --'),
58                         custom_placeholder: _('-- custom --'),
59                         display_items:      3,
60                         dropdown_items:     5,
61                         create:             false,
62                         create_query:       '.create-item-input',
63                         create_template:    'script[type="item-template"]'
64                 }, options);
65         },
66
67         render: function() {
68                 var sb = E('div', {
69                         'id': this.options.id,
70                         'class': 'cbi-dropdown',
71                         'multiple': this.options.multi ? '' : null,
72                         'optional': this.options.optional ? '' : null,
73                 }, E('ul'));
74
75                 var keys = Object.keys(this.choices);
76
77                 if (this.options.sort === true)
78                         keys.sort();
79                 else if (Array.isArray(this.options.sort))
80                         keys = this.options.sort;
81
82                 if (this.options.create)
83                         for (var i = 0; i < this.values.length; i++)
84                                 if (!this.choices.hasOwnProperty(this.values[i]))
85                                         keys.push(this.values[i]);
86
87                 for (var i = 0; i < keys.length; i++)
88                         sb.lastElementChild.appendChild(E('li', {
89                                 'data-value': keys[i],
90                                 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
91                         }, this.choices[keys[i]] || keys[i]));
92
93                 if (this.options.create) {
94                         var createEl = E('input', {
95                                 'type': 'text',
96                                 'class': 'create-item-input',
97                                 'placeholder': this.options.custom_placeholder || this.options.placeholder
98                         });
99
100                         if (this.options.datatype)
101                                 L.ui.addValidator(createEl, this.options.datatype, true, 'blur', 'keyup');
102
103                         sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
104                 }
105
106                 return this.bind(sb);
107         },
108
109         bind: function(sb) {
110                 var o = this.options;
111
112                 o.multi = sb.hasAttribute('multiple');
113                 o.optional = sb.hasAttribute('optional');
114                 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
115                 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
116                 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
117                 o.create_query = sb.getAttribute('item-create') || o.create_query;
118                 o.create_template = sb.getAttribute('item-template') || o.create_template;
119
120                 var ul = sb.querySelector('ul'),
121                     more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
122                     open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
123                     canary = sb.appendChild(E('div')),
124                     create = sb.querySelector(this.options.create_query),
125                     ndisplay = this.options.display_items,
126                     n = 0;
127
128                 if (this.options.multi) {
129                         var items = ul.querySelectorAll('li');
130
131                         for (var i = 0; i < items.length; i++) {
132                                 this.transformItem(sb, items[i]);
133
134                                 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
135                                         items[i].setAttribute('display', n++);
136                         }
137                 }
138                 else {
139                         if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
140                                 var placeholder = E('li', { placeholder: '' },
141                                         this.options.select_placeholder || this.options.placeholder);
142
143                                 ul.firstChild
144                                         ? ul.insertBefore(placeholder, ul.firstChild)
145                                         : ul.appendChild(placeholder);
146                         }
147
148                         var items = ul.querySelectorAll('li'),
149                             sel = sb.querySelectorAll('[selected]');
150
151                         sel.forEach(function(s) {
152                                 s.removeAttribute('selected');
153                         });
154
155                         var s = sel[0] || items[0];
156                         if (s) {
157                                 s.setAttribute('selected', '');
158                                 s.setAttribute('display', n++);
159                         }
160
161                         ndisplay--;
162                 }
163
164                 this.saveValues(sb, ul);
165
166                 ul.setAttribute('tabindex', -1);
167                 sb.setAttribute('tabindex', 0);
168
169                 if (ndisplay < 0)
170                         sb.setAttribute('more', '')
171                 else
172                         sb.removeAttribute('more');
173
174                 if (ndisplay == this.options.display_items)
175                         sb.setAttribute('empty', '')
176                 else
177                         sb.removeAttribute('empty');
178
179                 more.innerHTML = (ndisplay == this.options.display_items)
180                         ? (this.options.select_placeholder || this.options.placeholder) : '···';
181
182
183                 sb.addEventListener('click', this.handleClick.bind(this));
184                 sb.addEventListener('keydown', this.handleKeydown.bind(this));
185                 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
186                 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
187
188                 if ('ontouchstart' in window) {
189                         sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
190                         window.addEventListener('touchstart', this.closeAllDropdowns);
191                 }
192                 else {
193                         sb.addEventListener('mouseover', this.handleMouseover.bind(this));
194                         sb.addEventListener('focus', this.handleFocus.bind(this));
195
196                         canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
197
198                         window.addEventListener('mouseover', this.setFocus);
199                         window.addEventListener('click', this.closeAllDropdowns);
200                 }
201
202                 if (create) {
203                         create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
204                         create.addEventListener('focus', this.handleCreateFocus.bind(this));
205                         create.addEventListener('blur', this.handleCreateBlur.bind(this));
206
207                         var li = findParent(create, 'li');
208
209                         li.setAttribute('unselectable', '');
210                         li.addEventListener('click', this.handleCreateClick.bind(this));
211                 }
212
213                 this.node = sb;
214
215                 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
216                 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
217
218                 L.dom.bindClassInstance(sb, this);
219
220                 return sb;
221         },
222
223         openDropdown: function(sb) {
224                 var st = window.getComputedStyle(sb, null),
225                     ul = sb.querySelector('ul'),
226                     li = ul.querySelectorAll('li'),
227                     fl = findParent(sb, '.cbi-value-field'),
228                     sel = ul.querySelector('[selected]'),
229                     rect = sb.getBoundingClientRect(),
230                     items = Math.min(this.options.dropdown_items, li.length);
231
232                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
233                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
234                 });
235
236                 sb.setAttribute('open', '');
237
238                 var pv = ul.cloneNode(true);
239                     pv.classList.add('preview');
240
241                 if (fl)
242                         fl.classList.add('cbi-dropdown-open');
243
244                 if ('ontouchstart' in window) {
245                         var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
246                             vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
247                             scrollFrom = window.pageYOffset,
248                             scrollTo = scrollFrom + rect.top - vpHeight * 0.5,
249                             start = null;
250
251                         ul.style.top = sb.offsetHeight + 'px';
252                         ul.style.left = -rect.left + 'px';
253                         ul.style.right = (rect.right - vpWidth) + 'px';
254                         ul.style.maxHeight = (vpHeight * 0.5) + 'px';
255                         ul.style.WebkitOverflowScrolling = 'touch';
256
257                         var scrollStep = function(timestamp) {
258                                 if (!start) {
259                                         start = timestamp;
260                                         ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
261                                 }
262
263                                 var duration = Math.max(timestamp - start, 1);
264                                 if (duration < 100) {
265                                         document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
266                                         window.requestAnimationFrame(scrollStep);
267                                 }
268                                 else {
269                                         document.body.scrollTop = scrollTo;
270                                 }
271                         };
272
273                         window.requestAnimationFrame(scrollStep);
274                 }
275                 else {
276                         ul.style.maxHeight = '1px';
277                         ul.style.top = ul.style.bottom = '';
278
279                         window.requestAnimationFrame(function() {
280                                 var height = items * li[Math.max(0, li.length - 2)].offsetHeight;
281
282                                 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
283                                 ul.style[((rect.top + rect.height + height) > window.innerHeight) ? 'bottom' : 'top'] = rect.height + 'px';
284                                 ul.style.maxHeight = height + 'px';
285                         });
286                 }
287
288                 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
289                 for (var i = 0; i < cboxes.length; i++) {
290                         cboxes[i].checked = true;
291                         cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
292                 };
293
294                 ul.classList.add('dropdown');
295
296                 sb.insertBefore(pv, ul.nextElementSibling);
297
298                 li.forEach(function(l) {
299                         l.setAttribute('tabindex', 0);
300                 });
301
302                 sb.lastElementChild.setAttribute('tabindex', 0);
303
304                 this.setFocus(sb, sel || li[0], true);
305         },
306
307         closeDropdown: function(sb, no_focus) {
308                 if (!sb.hasAttribute('open'))
309                         return;
310
311                 var pv = sb.querySelector('ul.preview'),
312                     ul = sb.querySelector('ul.dropdown'),
313                     li = ul.querySelectorAll('li'),
314                     fl = findParent(sb, '.cbi-value-field');
315
316                 li.forEach(function(l) { l.removeAttribute('tabindex'); });
317                 sb.lastElementChild.removeAttribute('tabindex');
318
319                 sb.removeChild(pv);
320                 sb.removeAttribute('open');
321                 sb.style.width = sb.style.height = '';
322
323                 ul.classList.remove('dropdown');
324                 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
325
326                 if (fl)
327                         fl.classList.remove('cbi-dropdown-open');
328
329                 if (!no_focus)
330                         this.setFocus(sb, sb);
331
332                 this.saveValues(sb, ul);
333         },
334
335         toggleItem: function(sb, li, force_state) {
336                 if (li.hasAttribute('unselectable'))
337                         return;
338
339                 if (this.options.multi) {
340                         var cbox = li.querySelector('input[type="checkbox"]'),
341                             items = li.parentNode.querySelectorAll('li'),
342                             label = sb.querySelector('ul.preview'),
343                             sel = li.parentNode.querySelectorAll('[selected]').length,
344                             more = sb.querySelector('.more'),
345                             ndisplay = this.options.display_items,
346                             n = 0;
347
348                         if (li.hasAttribute('selected')) {
349                                 if (force_state !== true) {
350                                         if (sel > 1 || this.options.optional) {
351                                                 li.removeAttribute('selected');
352                                                 cbox.checked = cbox.disabled = false;
353                                                 sel--;
354                                         }
355                                         else {
356                                                 cbox.disabled = true;
357                                         }
358                                 }
359                         }
360                         else {
361                                 if (force_state !== false) {
362                                         li.setAttribute('selected', '');
363                                         cbox.checked = true;
364                                         cbox.disabled = false;
365                                         sel++;
366                                 }
367                         }
368
369                         while (label && label.firstElementChild)
370                                 label.removeChild(label.firstElementChild);
371
372                         for (var i = 0; i < items.length; i++) {
373                                 items[i].removeAttribute('display');
374                                 if (items[i].hasAttribute('selected')) {
375                                         if (ndisplay-- > 0) {
376                                                 items[i].setAttribute('display', n++);
377                                                 if (label)
378                                                         label.appendChild(items[i].cloneNode(true));
379                                         }
380                                         var c = items[i].querySelector('input[type="checkbox"]');
381                                         if (c)
382                                                 c.disabled = (sel == 1 && !this.options.optional);
383                                 }
384                         }
385
386                         if (ndisplay < 0)
387                                 sb.setAttribute('more', '');
388                         else
389                                 sb.removeAttribute('more');
390
391                         if (ndisplay === this.options.display_items)
392                                 sb.setAttribute('empty', '');
393                         else
394                                 sb.removeAttribute('empty');
395
396                         more.innerHTML = (ndisplay === this.options.display_items)
397                                 ? (this.options.select_placeholder || this.options.placeholder) : '···';
398                 }
399                 else {
400                         var sel = li.parentNode.querySelector('[selected]');
401                         if (sel) {
402                                 sel.removeAttribute('display');
403                                 sel.removeAttribute('selected');
404                         }
405
406                         li.setAttribute('display', 0);
407                         li.setAttribute('selected', '');
408
409                         this.closeDropdown(sb, true);
410                 }
411
412                 this.saveValues(sb, li.parentNode);
413         },
414
415         transformItem: function(sb, li) {
416                 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
417                     label = E('label');
418
419                 while (li.firstChild)
420                         label.appendChild(li.firstChild);
421
422                 li.appendChild(cbox);
423                 li.appendChild(label);
424         },
425
426         saveValues: function(sb, ul) {
427                 var sel = ul.querySelectorAll('li[selected]'),
428                     div = sb.lastElementChild,
429                     name = this.options.name,
430                     strval = '',
431                     values = [];
432
433                 while (div.lastElementChild)
434                         div.removeChild(div.lastElementChild);
435
436                 sel.forEach(function (s) {
437                         if (s.hasAttribute('placeholder'))
438                                 return;
439
440                         var v = {
441                                 text: s.innerText,
442                                 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
443                                 element: s
444                         };
445
446                         div.appendChild(E('input', {
447                                 type: 'hidden',
448                                 name: name,
449                                 value: v.value
450                         }));
451
452                         values.push(v);
453
454                         strval += strval.length ? ' ' + v.value : v.value;
455                 });
456
457                 var detail = {
458                         instance: this,
459                         element: sb
460                 };
461
462                 if (this.options.multi)
463                         detail.values = values;
464                 else
465                         detail.value = values.length ? values[0] : null;
466
467                 sb.value = strval;
468
469                 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
470                         bubbles: true,
471                         detail: detail
472                 }));
473         },
474
475         setValues: function(sb, values) {
476                 var ul = sb.querySelector('ul');
477
478                 if (this.options.create) {
479                         for (var value in values) {
480                                 this.createItems(sb, value);
481
482                                 if (!this.options.multi)
483                                         break;
484                         }
485                 }
486
487                 if (this.options.multi) {
488                         var lis = ul.querySelectorAll('li[data-value]');
489                         for (var i = 0; i < lis.length; i++) {
490                                 var value = lis[i].getAttribute('data-value');
491                                 if (values === null || !(value in values))
492                                         this.toggleItem(sb, lis[i], false);
493                                 else
494                                         this.toggleItem(sb, lis[i], true);
495                         }
496                 }
497                 else {
498                         var ph = ul.querySelector('li[placeholder]');
499                         if (ph)
500                                 this.toggleItem(sb, ph);
501
502                         var lis = ul.querySelectorAll('li[data-value]');
503                         for (var i = 0; i < lis.length; i++) {
504                                 var value = lis[i].getAttribute('data-value');
505                                 if (values !== null && (value in values))
506                                         this.toggleItem(sb, lis[i]);
507                         }
508                 }
509         },
510
511         setFocus: function(sb, elem, scroll) {
512                 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
513                         return;
514
515                 if (sb.target && findParent(sb.target, 'ul.dropdown'))
516                         return;
517
518                 document.querySelectorAll('.focus').forEach(function(e) {
519                         if (!matchesElem(e, 'input')) {
520                                 e.classList.remove('focus');
521                                 e.blur();
522                         }
523                 });
524
525                 if (elem) {
526                         elem.focus();
527                         elem.classList.add('focus');
528
529                         if (scroll)
530                                 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
531                 }
532         },
533
534         createItems: function(sb, value) {
535                 var sbox = this,
536                     val = (value || '').trim(),
537                     ul = sb.querySelector('ul');
538
539                 if (!sbox.options.multi)
540                         val = val.length ? [ val ] : [];
541                 else
542                         val = val.length ? val.split(/\s+/) : [];
543
544                 val.forEach(function(item) {
545                         var new_item = null;
546
547                         ul.childNodes.forEach(function(li) {
548                                 if (li.getAttribute && li.getAttribute('data-value') === item)
549                                         new_item = li;
550                         });
551
552                         if (!new_item) {
553                                 var markup,
554                                     tpl = sb.querySelector(sbox.options.create_template);
555
556                                 if (tpl)
557                                         markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
558                                 else
559                                         markup = '<li data-value="{{value}}">{{value}}</li>';
560
561                                 new_item = E(markup.replace(/{{value}}/g, item));
562
563                                 if (sbox.options.multi) {
564                                         sbox.transformItem(sb, new_item);
565                                 }
566                                 else {
567                                         var old = ul.querySelector('li[created]');
568                                         if (old)
569                                                 ul.removeChild(old);
570
571                                         new_item.setAttribute('created', '');
572                                 }
573
574                                 new_item = ul.insertBefore(new_item, ul.lastElementChild);
575                         }
576
577                         sbox.toggleItem(sb, new_item, true);
578                         sbox.setFocus(sb, new_item, true);
579                 });
580         },
581
582         closeAllDropdowns: function() {
583                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
584                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
585                 });
586         },
587
588         handleClick: function(ev) {
589                 var sb = ev.currentTarget;
590
591                 if (!sb.hasAttribute('open')) {
592                         if (!matchesElem(ev.target, 'input'))
593                                 this.openDropdown(sb);
594                 }
595                 else {
596                         var li = findParent(ev.target, 'li');
597                         if (li && li.parentNode.classList.contains('dropdown'))
598                                 this.toggleItem(sb, li);
599                         else if (li && li.parentNode.classList.contains('preview'))
600                                 this.closeDropdown(sb);
601                 }
602
603                 ev.preventDefault();
604                 ev.stopPropagation();
605         },
606
607         handleKeydown: function(ev) {
608                 var sb = ev.currentTarget;
609
610                 if (matchesElem(ev.target, 'input'))
611                         return;
612
613                 if (!sb.hasAttribute('open')) {
614                         switch (ev.keyCode) {
615                         case 37:
616                         case 38:
617                         case 39:
618                         case 40:
619                                 this.openDropdown(sb);
620                                 ev.preventDefault();
621                         }
622                 }
623                 else {
624                         var active = findParent(document.activeElement, 'li');
625
626                         switch (ev.keyCode) {
627                         case 27:
628                                 this.closeDropdown(sb);
629                                 break;
630
631                         case 13:
632                                 if (active) {
633                                         if (!active.hasAttribute('selected'))
634                                                 this.toggleItem(sb, active);
635                                         this.closeDropdown(sb);
636                                         ev.preventDefault();
637                                 }
638                                 break;
639
640                         case 32:
641                                 if (active) {
642                                         this.toggleItem(sb, active);
643                                         ev.preventDefault();
644                                 }
645                                 break;
646
647                         case 38:
648                                 if (active && active.previousElementSibling) {
649                                         this.setFocus(sb, active.previousElementSibling);
650                                         ev.preventDefault();
651                                 }
652                                 break;
653
654                         case 40:
655                                 if (active && active.nextElementSibling) {
656                                         this.setFocus(sb, active.nextElementSibling);
657                                         ev.preventDefault();
658                                 }
659                                 break;
660                         }
661                 }
662         },
663
664         handleDropdownClose: function(ev) {
665                 var sb = ev.currentTarget;
666
667                 this.closeDropdown(sb, true);
668         },
669
670         handleDropdownSelect: function(ev) {
671                 var sb = ev.currentTarget,
672                     li = findParent(ev.target, 'li');
673
674                 if (!li)
675                         return;
676
677                 this.toggleItem(sb, li);
678                 this.closeDropdown(sb, true);
679         },
680
681         handleMouseover: function(ev) {
682                 var sb = ev.currentTarget;
683
684                 if (!sb.hasAttribute('open'))
685                         return;
686
687                 var li = findParent(ev.target, 'li');
688
689                 if (li && li.parentNode.classList.contains('dropdown'))
690                         this.setFocus(sb, li);
691         },
692
693         handleFocus: function(ev) {
694                 var sb = ev.currentTarget;
695
696                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
697                         if (s !== sb || sb.hasAttribute('open'))
698                                 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
699                 });
700         },
701
702         handleCanaryFocus: function(ev) {
703                 this.closeDropdown(ev.currentTarget.parentNode);
704         },
705
706         handleCreateKeydown: function(ev) {
707                 var input = ev.currentTarget,
708                     sb = findParent(input, '.cbi-dropdown');
709
710                 switch (ev.keyCode) {
711                 case 13:
712                         ev.preventDefault();
713
714                         if (input.classList.contains('cbi-input-invalid'))
715                                 return;
716
717                         this.createItems(sb, input.value);
718                         input.value = '';
719                         input.blur();
720                         break;
721                 }
722         },
723
724         handleCreateFocus: function(ev) {
725                 var input = ev.currentTarget,
726                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
727                     sb = findParent(input, '.cbi-dropdown');
728
729                 if (cbox)
730                         cbox.checked = true;
731
732                 sb.setAttribute('locked-in', '');
733         },
734
735         handleCreateBlur: function(ev) {
736                 var input = ev.currentTarget,
737                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
738                     sb = findParent(input, '.cbi-dropdown');
739
740                 if (cbox)
741                         cbox.checked = false;
742
743                 sb.removeAttribute('locked-in');
744         },
745
746         handleCreateClick: function(ev) {
747                 ev.currentTarget.querySelector(this.options.create_query).focus();
748         },
749
750         setValue: function(values) {
751                 if (this.options.multi) {
752                         if (!Array.isArray(values))
753                                 values = (values != null) ? [ values ] : [];
754
755                         var v = {};
756
757                         for (var i = 0; i < values.length; i++)
758                                 v[values[i]] = true;
759
760                         this.setValues(this.node, v);
761                 }
762                 else {
763                         var v = {};
764
765                         if (values != null) {
766                                 if (Array.isArray(values))
767                                         v[values[0]] = true;
768                                 else
769                                         v[values] = true;
770                         }
771
772                         this.setValues(this.node, v);
773                 }
774         },
775
776         getValue: function() {
777                 var div = this.node.lastElementChild,
778                     h = div.querySelectorAll('input[type="hidden"]'),
779                         v = [];
780
781                 for (var i = 0; i < h.length; i++)
782                         v.push(h[i].value);
783
784                 return this.options.multi ? v : v[0];
785         }
786 });
787
788 var UICombobox = UIDropdown.extend({
789         __init__: function(value, choices, options) {
790                 this.super('__init__', [ value, choices, Object.assign({
791                         select_placeholder: _('-- Please choose --'),
792                         custom_placeholder: _('-- custom --'),
793                         dropdown_items: 5
794                 }, options, {
795                         sort: true,
796                         multi: false,
797                         create: true,
798                         optional: true
799                 }) ]);
800         }
801 });
802
803 var UIDynamicList = UIElement.extend({
804         __init__: function(values, choices, options) {
805                 if (!Array.isArray(values))
806                         values = (values != null) ? [ values ] : [];
807
808                 if (typeof(choices) != 'object')
809                         choices = null;
810
811                 this.values = values;
812                 this.choices = choices;
813                 this.options = Object.assign({}, options, {
814                         multi: false,
815                         optional: true
816                 });
817         },
818
819         render: function() {
820                 var dl = E('div', {
821                         'id': this.options.id,
822                         'class': 'cbi-dynlist'
823                 }, E('div', { 'class': 'add-item' }));
824
825                 if (this.choices) {
826                         var cbox = new UICombobox(null, this.choices, this.options);
827                         dl.lastElementChild.appendChild(cbox.render());
828                 }
829                 else {
830                         var inputEl = E('input', {
831                                 'type': 'text',
832                                 'class': 'cbi-input-text',
833                                 'placeholder': this.options.placeholder
834                         });
835
836                         dl.lastElementChild.appendChild(inputEl);
837                         dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
838
839                         L.ui.addValidator(inputEl, this.options.datatype, true, 'blue', 'keyup');
840                 }
841
842                 for (var i = 0; i < this.values.length; i++)
843                         this.addItem(dl, this.values[i],
844                                 this.choices ? this.choices[this.values[i]] : null);
845
846                 return this.bind(dl);
847         },
848
849         bind: function(dl) {
850                 dl.addEventListener('click', L.bind(this.handleClick, this));
851                 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
852                 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
853
854                 this.node = dl;
855
856                 this.setUpdateEvents(dl, 'cbi-dynlist-change');
857                 this.setChangeEvents(dl, 'cbi-dynlist-change');
858
859                 L.dom.bindClassInstance(dl, this);
860
861                 return dl;
862         },
863
864         addItem: function(dl, value, text, flash) {
865                 var exists = false,
866                     new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
867                                 E('span', {}, text || value),
868                                 E('input', {
869                                         'type': 'hidden',
870                                         'name': this.options.name,
871                                         'value': value })]);
872
873                 dl.querySelectorAll('.item, .add-item').forEach(function(item) {
874                         if (exists)
875                                 return;
876
877                         var hidden = item.querySelector('input[type="hidden"]');
878
879                         if (hidden && hidden.parentNode !== item)
880                                 hidden = null;
881
882                         if (hidden && hidden.value === value)
883                                 exists = true;
884                         else if (!hidden || hidden.value >= value)
885                                 exists = !!item.parentNode.insertBefore(new_item, item);
886                 });
887
888                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
889                         bubbles: true,
890                         detail: {
891                                 instance: this,
892                                 element: dl,
893                                 value: value,
894                                 add: true
895                         }
896                 }));
897         },
898
899         removeItem: function(dl, item) {
900                 var value = item.querySelector('input[type="hidden"]').value;
901                 var sb = dl.querySelector('.cbi-dropdown');
902                 if (sb)
903                         sb.querySelectorAll('ul > li').forEach(function(li) {
904                                 if (li.getAttribute('data-value') === value) {
905                                         if (li.hasAttribute('dynlistcustom'))
906                                                 li.parentNode.removeChild(li);
907                                         else
908                                                 li.removeAttribute('unselectable');
909                                 }
910                         });
911
912                 item.parentNode.removeChild(item);
913
914                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
915                         bubbles: true,
916                         detail: {
917                                 instance: this,
918                                 element: dl,
919                                 value: value,
920                                 remove: true
921                         }
922                 }));
923         },
924
925         handleClick: function(ev) {
926                 var dl = ev.currentTarget,
927                     item = findParent(ev.target, '.item');
928
929                 if (item) {
930                         this.removeItem(dl, item);
931                 }
932                 else if (matchesElem(ev.target, '.cbi-button-add')) {
933                         var input = ev.target.previousElementSibling;
934                         if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
935                                 this.addItem(dl, input.value, null, true);
936                                 input.value = '';
937                         }
938                 }
939         },
940
941         handleDropdownChange: function(ev) {
942                 var dl = ev.currentTarget,
943                     sbIn = ev.detail.instance,
944                     sbEl = ev.detail.element,
945                     sbVal = ev.detail.value;
946
947                 if (sbVal === null)
948                         return;
949
950                 sbIn.setValues(sbEl, null);
951                 sbVal.element.setAttribute('unselectable', '');
952
953                 if (sbVal.element.hasAttribute('created')) {
954                         sbVal.element.removeAttribute('created');
955                         sbVal.element.setAttribute('dynlistcustom', '');
956                 }
957
958                 this.addItem(dl, sbVal.value, sbVal.text, true);
959         },
960
961         handleKeydown: function(ev) {
962                 var dl = ev.currentTarget,
963                     item = findParent(ev.target, '.item');
964
965                 if (item) {
966                         switch (ev.keyCode) {
967                         case 8: /* backspace */
968                                 if (item.previousElementSibling)
969                                         item.previousElementSibling.focus();
970
971                                 this.removeItem(dl, item);
972                                 break;
973
974                         case 46: /* delete */
975                                 if (item.nextElementSibling) {
976                                         if (item.nextElementSibling.classList.contains('item'))
977                                                 item.nextElementSibling.focus();
978                                         else
979                                                 item.nextElementSibling.firstElementChild.focus();
980                                 }
981
982                                 this.removeItem(dl, item);
983                                 break;
984                         }
985                 }
986                 else if (matchesElem(ev.target, '.cbi-input-text')) {
987                         switch (ev.keyCode) {
988                         case 13: /* enter */
989                                 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
990                                         this.addItem(dl, ev.target.value, null, true);
991                                         ev.target.value = '';
992                                         ev.target.blur();
993                                         ev.target.focus();
994                                 }
995
996                                 ev.preventDefault();
997                                 break;
998                         }
999                 }
1000         },
1001
1002         getValue: function() {
1003                 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
1004                     v = [];
1005
1006                 for (var i = 0; i < items.length; i++)
1007                         v.push(items[i].value);
1008
1009                 return v;
1010         },
1011
1012         setValue: function(values) {
1013                 if (!Array.isArray(values))
1014                         values = (values != null) ? [ values ] : [];
1015
1016                 var items = this.node.querySelectorAll('.item');
1017
1018                 for (var i = 0; i < items.length; i++)
1019                         if (items[i].parentNode === this.node)
1020                                 this.removeItem(this.node, items[i]);
1021
1022                 for (var i = 0; i < values.length; i++)
1023                         this.addItem(this.node, values[i],
1024                                 this.choices ? this.choices[values[i]] : null);
1025         }
1026 });
1027
1028
1029 return L.Class.extend({
1030         __init__: function() {
1031                 modalDiv = document.body.appendChild(
1032                         L.dom.create('div', { id: 'modal_overlay' },
1033                                 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
1034
1035                 tooltipDiv = document.body.appendChild(
1036                         L.dom.create('div', { class: 'cbi-tooltip' }));
1037
1038                 /* setup old aliases */
1039                 L.showModal = this.showModal;
1040                 L.hideModal = this.hideModal;
1041                 L.showTooltip = this.showTooltip;
1042                 L.hideTooltip = this.hideTooltip;
1043                 L.itemlist = this.itemlist;
1044
1045                 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
1046                 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
1047                 document.addEventListener('focus', this.showTooltip.bind(this), true);
1048                 document.addEventListener('blur', this.hideTooltip.bind(this), true);
1049
1050                 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
1051         },
1052
1053         /* Modal dialog */
1054         showModal: function(title, children) {
1055                 var dlg = modalDiv.firstElementChild;
1056
1057                 dlg.setAttribute('class', 'modal');
1058
1059                 L.dom.content(dlg, L.dom.create('h4', {}, title));
1060                 L.dom.append(dlg, children);
1061
1062                 document.body.classList.add('modal-overlay-active');
1063
1064                 return dlg;
1065         },
1066
1067         hideModal: function() {
1068                 document.body.classList.remove('modal-overlay-active');
1069         },
1070
1071         /* Tooltip */
1072         showTooltip: function(ev) {
1073                 var target = findParent(ev.target, '[data-tooltip]');
1074
1075                 if (!target)
1076                         return;
1077
1078                 if (tooltipTimeout !== null) {
1079                         window.clearTimeout(tooltipTimeout);
1080                         tooltipTimeout = null;
1081                 }
1082
1083                 var rect = target.getBoundingClientRect(),
1084                     x = rect.left              + window.pageXOffset,
1085                     y = rect.top + rect.height + window.pageYOffset;
1086
1087                 tooltipDiv.className = 'cbi-tooltip';
1088                 tooltipDiv.innerHTML = '▲ ';
1089                 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
1090
1091                 if (target.hasAttribute('data-tooltip-style'))
1092                         tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
1093
1094                 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
1095                         y -= (tooltipDiv.offsetHeight + target.offsetHeight);
1096                         tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
1097                 }
1098
1099                 tooltipDiv.style.top = y + 'px';
1100                 tooltipDiv.style.left = x + 'px';
1101                 tooltipDiv.style.opacity = 1;
1102
1103                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
1104                         bubbles: true,
1105                         detail: { target: target }
1106                 }));
1107         },
1108
1109         hideTooltip: function(ev) {
1110                 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
1111                     tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
1112                         return;
1113
1114                 if (tooltipTimeout !== null) {
1115                         window.clearTimeout(tooltipTimeout);
1116                         tooltipTimeout = null;
1117                 }
1118
1119                 tooltipDiv.style.opacity = 0;
1120                 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
1121
1122                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
1123         },
1124
1125         /* Widget helper */
1126         itemlist: function(node, items, separators) {
1127                 var children = [];
1128
1129                 if (!Array.isArray(separators))
1130                         separators = [ separators || E('br') ];
1131
1132                 for (var i = 0; i < items.length; i += 2) {
1133                         if (items[i+1] !== null && items[i+1] !== undefined) {
1134                                 var sep = separators[(i/2) % separators.length],
1135                                     cld = [];
1136
1137                                 children.push(E('span', { class: 'nowrap' }, [
1138                                         items[i] ? E('strong', items[i] + ': ') : '',
1139                                         items[i+1]
1140                                 ]));
1141
1142                                 if ((i+2) < items.length)
1143                                         children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
1144                         }
1145                 }
1146
1147                 L.dom.content(node, children);
1148
1149                 return node;
1150         },
1151
1152         /* Tabs */
1153         tabs: L.Class.singleton({
1154                 init: function() {
1155                         var groups = [], prevGroup = null, currGroup = null;
1156
1157                         document.querySelectorAll('[data-tab]').forEach(function(tab) {
1158                                 var parent = tab.parentNode;
1159
1160                                 if (!parent.hasAttribute('data-tab-group'))
1161                                         parent.setAttribute('data-tab-group', groups.length);
1162
1163                                 currGroup = +parent.getAttribute('data-tab-group');
1164
1165                                 if (currGroup !== prevGroup) {
1166                                         prevGroup = currGroup;
1167
1168                                         if (!groups[currGroup])
1169                                                 groups[currGroup] = [];
1170                                 }
1171
1172                                 groups[currGroup].push(tab);
1173                         });
1174
1175                         for (var i = 0; i < groups.length; i++)
1176                                 this.initTabGroup(groups[i]);
1177
1178                         document.addEventListener('dependency-update', this.updateTabs.bind(this));
1179
1180                         this.updateTabs();
1181
1182                         if (!groups.length)
1183                                 this.setActiveTabId(-1, -1);
1184                 },
1185
1186                 initTabGroup: function(panes) {
1187                         if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
1188                                 return;
1189
1190                         var menu = E('ul', { 'class': 'cbi-tabmenu' }),
1191                             group = panes[0].parentNode,
1192                             groupId = +group.getAttribute('data-tab-group'),
1193                             selected = null;
1194
1195                         for (var i = 0, pane; pane = panes[i]; i++) {
1196                                 var name = pane.getAttribute('data-tab'),
1197                                     title = pane.getAttribute('data-tab-title'),
1198                                     active = pane.getAttribute('data-tab-active') === 'true';
1199
1200                                 menu.appendChild(E('li', {
1201                                         'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
1202                                         'data-tab': name
1203                                 }, E('a', {
1204                                         'href': '#',
1205                                         'click': this.switchTab.bind(this)
1206                                 }, title)));
1207
1208                                 if (active)
1209                                         selected = i;
1210                         }
1211
1212                         group.parentNode.insertBefore(menu, group);
1213
1214                         if (selected === null) {
1215                                 selected = this.getActiveTabId(groupId);
1216
1217                                 if (selected < 0 || selected >= panes.length)
1218                                         selected = 0;
1219
1220                                 menu.childNodes[selected].classList.add('cbi-tab');
1221                                 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
1222                                 panes[selected].setAttribute('data-tab-active', 'true');
1223
1224                                 this.setActiveTabId(groupId, selected);
1225                         }
1226                 },
1227
1228                 getActiveTabState: function() {
1229                         var page = document.body.getAttribute('data-page');
1230
1231                         try {
1232                                 var val = JSON.parse(window.sessionStorage.getItem('tab'));
1233                                 if (val.page === page && Array.isArray(val.groups))
1234                                         return val;
1235                         }
1236                         catch(e) {}
1237
1238                         window.sessionStorage.removeItem('tab');
1239                         return { page: page, groups: [] };
1240                 },
1241
1242                 getActiveTabId: function(groupId) {
1243                         return +this.getActiveTabState().groups[groupId] || 0;
1244                 },
1245
1246                 setActiveTabId: function(groupId, tabIndex) {
1247                         try {
1248                                 var state = this.getActiveTabState();
1249                                     state.groups[groupId] = tabIndex;
1250
1251                             window.sessionStorage.setItem('tab', JSON.stringify(state));
1252                         }
1253                         catch (e) { return false; }
1254
1255                         return true;
1256                 },
1257
1258                 updateTabs: function(ev) {
1259                         document.querySelectorAll('[data-tab-title]').forEach(function(pane) {
1260                                 var menu = pane.parentNode.previousElementSibling,
1261                                     tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))),
1262                                     n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
1263
1264                                 if (!pane.firstElementChild) {
1265                                         tab.style.display = 'none';
1266                                         tab.classList.remove('flash');
1267                                 }
1268                                 else if (tab.style.display === 'none') {
1269                                         tab.style.display = '';
1270                                         requestAnimationFrame(function() { tab.classList.add('flash') });
1271                                 }
1272
1273                                 if (n_errors) {
1274                                         tab.setAttribute('data-errors', n_errors);
1275                                         tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
1276                                         tab.setAttribute('data-tooltip-style', 'error');
1277                                 }
1278                                 else {
1279                                         tab.removeAttribute('data-errors');
1280                                         tab.removeAttribute('data-tooltip');
1281                                 }
1282                         });
1283                 },
1284
1285                 switchTab: function(ev) {
1286                         var tab = ev.target.parentNode,
1287                             name = tab.getAttribute('data-tab'),
1288                             menu = tab.parentNode,
1289                             group = menu.nextElementSibling,
1290                             groupId = +group.getAttribute('data-tab-group'),
1291                             index = 0;
1292
1293                         ev.preventDefault();
1294
1295                         if (!tab.classList.contains('cbi-tab-disabled'))
1296                                 return;
1297
1298                         menu.querySelectorAll('[data-tab]').forEach(function(tab) {
1299                                 tab.classList.remove('cbi-tab');
1300                                 tab.classList.remove('cbi-tab-disabled');
1301                                 tab.classList.add(
1302                                         tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
1303                         });
1304
1305                         group.childNodes.forEach(function(pane) {
1306                                 if (L.dom.matches(pane, '[data-tab]')) {
1307                                         if (pane.getAttribute('data-tab') === name) {
1308                                                 pane.setAttribute('data-tab-active', 'true');
1309                                                 L.ui.tabs.setActiveTabId(groupId, index);
1310                                         }
1311                                         else {
1312                                                 pane.setAttribute('data-tab-active', 'false');
1313                                         }
1314
1315                                         index++;
1316                                 }
1317                         });
1318                 }
1319         }),
1320
1321         addValidator: function(field, type, optional /*, ... */) {
1322                 if (type == null)
1323                         return;
1324
1325                 var events = this.varargs(arguments, 3);
1326                 if (events.length == 0)
1327                         events.push('blur', 'keyup');
1328
1329                 try {
1330                         var cbiValidator = new CBIValidator(field, type, optional),
1331                             validatorFn = cbiValidator.validate.bind(cbiValidator);
1332
1333                         for (var i = 0; i < events.length; i++)
1334                                 field.addEventListener(events[i], validatorFn);
1335
1336                         validatorFn();
1337                 }
1338                 catch (e) { }
1339         },
1340
1341         /* Widgets */
1342         Dropdown: UIDropdown,
1343         DynamicList: UIDynamicList,
1344         Combobox: UICombobox
1345 });