summaryrefslogtreecommitdiffhomepage
path: root/modules/luci-base/htdocs/luci-static/resources/ui.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/luci-base/htdocs/luci-static/resources/ui.js')
-rw-r--r--modules/luci-base/htdocs/luci-static/resources/ui.js777
1 files changed, 642 insertions, 135 deletions
diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js
index 91a93b9e90..5502dfed25 100644
--- a/modules/luci-base/htdocs/luci-static/resources/ui.js
+++ b/modules/luci-base/htdocs/luci-static/resources/ui.js
@@ -26,7 +26,7 @@ var modalDiv = null,
* events.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -102,6 +102,47 @@ var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */
},
/**
+ * Set the current placeholder value of the input widget.
+ *
+ * @instance
+ * @memberof LuCI.ui.AbstractElement
+ * @param {string|string[]|null} value
+ * The placeholder to set for the input element. Only applicable to text
+ * inputs, not to radio buttons, selects or similar.
+ */
+ setPlaceholder: function(value) {
+ var node = this.node ? this.node.querySelector('input,textarea') : null;
+ if (node) {
+ switch (node.getAttribute('type') || 'text') {
+ case 'password':
+ case 'search':
+ case 'tel':
+ case 'text':
+ case 'url':
+ if (value != null && value != '')
+ node.setAttribute('placeholder', value);
+ else
+ node.removeAttribute('placeholder');
+ }
+ }
+ },
+
+ /**
+ * Check whether the input value was altered by the user.
+ *
+ * @instance
+ * @memberof LuCI.ui.AbstractElement
+ * @returns {boolean}
+ * Returns `true` if the input value has been altered by the user or
+ * `false` if it is unchanged. Note that if the user modifies the initial
+ * value and changes it back to the original state, it is still reported
+ * as changed.
+ */
+ isChanged: function() {
+ return (this.node ? this.node.getAttribute('data-changed') : null) == 'true';
+ },
+
+ /**
* Check whether the current input value is valid.
*
* @instance
@@ -115,6 +156,18 @@ var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */
},
/**
+ * Returns the current validation error
+ *
+ * @instance
+ * @memberof LuCI.ui.AbstractElement
+ * @returns {string}
+ * The validation error at this time
+ */
+ getValidationError: function() {
+ return this.validationError || '';
+ },
+
+ /**
* Force validation of the current input value.
*
* Usually input validation is automatically triggered by various DOM events
@@ -202,10 +255,12 @@ var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */
this.node.addEventListener('validation-success', L.bind(function(ev) {
this.validState = true;
+ this.validationError = '';
}, this));
this.node.addEventListener('validation-failure', L.bind(function(ev) {
this.validState = false;
+ this.validationError = ev.detail.message;
}, this));
},
@@ -261,7 +316,7 @@ var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */
* The `Textfield` class implements a standard single line text input field.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -319,6 +374,7 @@ var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
'disabled': this.options.disabled ? '' : null,
'maxlength': this.options.maxlength,
'placeholder': this.options.placeholder,
+ 'autocomplete': this.options.password ? 'new-password' : null,
'value': this.value,
});
@@ -385,7 +441,7 @@ var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
* The `Textarea` class implements a multiline text area input field.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -501,7 +557,7 @@ var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
* The `Checkbox` class implements a simple checkbox input field.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -569,6 +625,22 @@ var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
frameEl.appendChild(E('label', { 'for': id }));
+ if (this.options.tooltip != null) {
+ var icon = "⚠️";
+
+ if (this.options.tooltipicon != null)
+ icon = this.options.tooltipicon;
+
+ frameEl.appendChild(
+ E('label', { 'class': 'cbi-tooltip-container' },[
+ icon,
+ E('div', { 'class': 'cbi-tooltip' },
+ this.options.tooltip
+ )
+ ])
+ );
+ }
+
return this.bind(frameEl);
},
@@ -576,8 +648,9 @@ var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
bind: function(frameEl) {
this.node = frameEl;
- this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
- this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
+ var input = frameEl.querySelector('input[type="checkbox"]');
+ this.setUpdateEvents(input, 'click', 'blur');
+ this.setChangeEvents(input, 'change');
dom.bindClassInstance(frameEl, this);
@@ -593,7 +666,7 @@ var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
* Returns `true` when the checkbox is currently checked, otherwise `false`.
*/
isChecked: function() {
- return this.node.lastElementChild.previousElementSibling.checked;
+ return this.node.querySelector('input[type="checkbox"]').checked;
},
/** @override */
@@ -605,7 +678,7 @@ var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
/** @override */
setValue: function(value) {
- this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
+ this.node.querySelector('input[type="checkbox"]').checked = (value == this.options.value_enabled);
}
});
@@ -623,7 +696,7 @@ var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
* values are enabled or not.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -653,7 +726,7 @@ var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
* @property {boolean} [multiple=false]
* Specifies whether multiple choice values may be selected.
*
- * @property {string} [widget=select]
+ * @property {"select"|"individual"} [widget=select]
* Specifies the kind of widget to render. May be either `select` or
* `individual`. When set to `select` an HTML `<select>` element will be
* used, otherwise a group of checkbox or radio button elements is created,
@@ -705,7 +778,7 @@ var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
keys = Object.keys(this.choices);
if (this.options.sort === true)
- keys.sort();
+ keys.sort(L.naturalCompare);
else if (Array.isArray(this.options.sort))
keys = this.options.sort;
@@ -831,7 +904,7 @@ var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
* supports non-text choice labels.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -978,13 +1051,14 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
'class': 'cbi-dropdown',
'multiple': this.options.multiple ? '' : null,
'optional': this.options.optional ? '' : null,
- 'disabled': this.options.disabled ? '' : null
+ 'disabled': this.options.disabled ? '' : null,
+ 'tabindex': -1
}, E('ul'));
var keys = Object.keys(this.choices);
if (this.options.sort === true)
- keys.sort();
+ keys.sort(L.naturalCompare);
else if (Array.isArray(this.options.sort))
keys = this.options.sort;
@@ -1114,11 +1188,11 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
}
else {
sb.addEventListener('mouseover', this.handleMouseover.bind(this));
+ sb.addEventListener('mouseout', this.handleMouseout.bind(this));
sb.addEventListener('focus', this.handleFocus.bind(this));
canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
- window.addEventListener('mouseover', this.setFocus);
window.addEventListener('click', this.closeAllDropdowns);
}
@@ -1144,6 +1218,28 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
},
/** @private */
+ getScrollParent: function(element) {
+ var parent = element,
+ style = getComputedStyle(element),
+ excludeStaticParent = (style.position === 'absolute');
+
+ if (style.position === 'fixed')
+ return document.body;
+
+ while ((parent = parent.parentElement) != null) {
+ style = getComputedStyle(parent);
+
+ if (excludeStaticParent && style.position === 'static')
+ continue;
+
+ if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
+ return parent;
+ }
+
+ return document.body;
+ },
+
+ /** @private */
openDropdown: function(sb) {
var st = window.getComputedStyle(sb, null),
ul = sb.querySelector('ul'),
@@ -1151,7 +1247,8 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
fl = findParent(sb, '.cbi-value-field'),
sel = ul.querySelector('[selected]'),
rect = sb.getBoundingClientRect(),
- items = Math.min(this.options.dropdown_items, li.length);
+ items = Math.min(this.options.dropdown_items, li.length),
+ scrollParent = this.getScrollParent(sb);
document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
@@ -1176,29 +1273,7 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
ul.style.maxHeight = (vpHeight * 0.5) + 'px';
ul.style.WebkitOverflowScrolling = 'touch';
- var getScrollParent = function(element) {
- var parent = element,
- style = getComputedStyle(element),
- excludeStaticParent = (style.position === 'absolute');
-
- if (style.position === 'fixed')
- return document.body;
-
- while ((parent = parent.parentElement) != null) {
- style = getComputedStyle(parent);
-
- if (excludeStaticParent && style.position === 'static')
- continue;
-
- if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
- return parent;
- }
-
- return document.body;
- }
-
- var scrollParent = getScrollParent(sb),
- scrollFrom = scrollParent.scrollTop,
+ var scrollFrom = scrollParent.scrollTop,
scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
var scrollStep = function(timestamp) {
@@ -1224,10 +1299,11 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
ul.style.top = ul.style.bottom = '';
window.requestAnimationFrame(function() {
- var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
+ var containerRect = scrollParent.getBoundingClientRect(),
+ itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
fullHeight = 0,
- spaceAbove = rect.top,
- spaceBelow = window.innerHeight - rect.height - rect.top;
+ spaceAbove = rect.top - containerRect.top,
+ spaceBelow = containerRect.bottom - rect.bottom;
for (var i = 0; i < (items == -1 ? li.length : items); i++)
fullHeight += li[i].getBoundingClientRect().height;
@@ -1269,7 +1345,12 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
sb.lastElementChild.setAttribute('tabindex', 0);
- this.setFocus(sb, sel || li[0], true);
+ var focusFn = L.bind(function(el) {
+ this.setFocus(sb, el, true);
+ ul.removeEventListener('transitionend', focusFn);
+ }, this, sel || li[0]);
+
+ ul.addEventListener('transitionend', focusFn);
},
/** @private */
@@ -1303,6 +1384,8 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
/** @private */
toggleItem: function(sb, li, force_state) {
+ var ul = li.parentNode;
+
if (li.hasAttribute('unselectable'))
return;
@@ -1379,7 +1462,7 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
this.closeDropdown(sb, true);
}
- this.saveValues(sb, li.parentNode);
+ this.saveValues(sb, ul);
},
/** @private */
@@ -1483,26 +1566,33 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
/** @private */
setFocus: function(sb, elem, scroll) {
- if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
+ if (sb.hasAttribute('locked-in'))
return;
- if (sb.target && findParent(sb.target, 'ul.dropdown'))
+ sb.querySelectorAll('.focus').forEach(function(e) {
+ e.classList.remove('focus');
+ });
+
+ elem.classList.add('focus');
+
+ if (scroll)
+ elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
+
+ elem.focus();
+ },
+
+ /** @private */
+ handleMouseout: function(ev) {
+ var sb = ev.currentTarget;
+
+ if (!sb.hasAttribute('open'))
return;
- document.querySelectorAll('.focus').forEach(function(e) {
- if (!matchesElem(e, 'input')) {
- e.classList.remove('focus');
- e.blur();
- }
+ sb.querySelectorAll('.focus').forEach(function(e) {
+ e.classList.remove('focus');
});
- if (elem) {
- elem.focus();
- elem.classList.add('focus');
-
- if (scroll)
- elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
- }
+ sb.querySelector('ul.dropdown').focus();
},
/** @private */
@@ -1682,7 +1772,8 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
/** @private */
handleKeydown: function(ev) {
- var sb = ev.currentTarget;
+ var sb = ev.currentTarget,
+ ul = sb.querySelector('ul.dropdown');
if (matchesElem(ev.target, 'input'))
return;
@@ -1703,6 +1794,7 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
switch (ev.keyCode) {
case 27:
this.closeDropdown(sb);
+ ev.stopPropagation();
break;
case 13:
@@ -1726,6 +1818,10 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
this.setFocus(sb, active.previousElementSibling);
ev.preventDefault();
}
+ else if (document.activeElement === ul) {
+ this.setFocus(sb, ul.lastElementChild);
+ ev.preventDefault();
+ }
break;
case 40:
@@ -1733,6 +1829,10 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
this.setFocus(sb, active.nextElementSibling);
ev.preventDefault();
}
+ else if (document.activeElement === ul) {
+ this.setFocus(sb, ul.firstElementChild);
+ ev.preventDefault();
+ }
break;
}
}
@@ -1888,7 +1988,7 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
* with a set of enforced default properties for easier instantiation.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -1955,7 +2055,7 @@ var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
* into a dropdown to chose from a set of different action choices.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -1978,7 +2078,7 @@ var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype *
/**
* ComboButtons support the same properties as
* [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
- * specific values for some properties and add aditional button specific
+ * specific values for some properties and add additional button specific
* properties.
*
* @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
@@ -2072,7 +2172,7 @@ var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype *
* from a set of predefined choices.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -2095,7 +2195,7 @@ var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype *
*/
var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
/**
- * In case choices are passed to the dynamic list contructor, the widget
+ * In case choices are passed to the dynamic list constructor, the widget
* supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
* but enforces specific values for some dropdown properties.
*
@@ -2132,7 +2232,7 @@ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */
'id': this.options.id,
'class': 'cbi-dynlist',
'disabled': this.options.disabled ? '' : null
- }, E('div', { 'class': 'add-item' }));
+ }, E('div', { 'class': 'add-item control-group' }));
if (this.choices) {
if (this.options.placeholder != null)
@@ -2427,7 +2527,7 @@ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */
* which allows to store form data without exposing it to the user.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -2493,7 +2593,7 @@ var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */
* browse, select and delete files beneath a predefined remote directory.
*
* UI widget instances are usually not supposed to be created by view code
- * directly, instead they're implicitely created by `LuCI.form` when
+ * directly, instead they're implicitly created by `LuCI.form` when
* instantiating CBI forms.
*
* This class is automatically instantiated as part of `LuCI.ui`. To use it
@@ -2538,9 +2638,9 @@ var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
*
* @property {string} [root_directory=/etc/luci-uploads]
* Specifies the remote directory the upload and file browsing actions take
- * place in. Browsing to directories outside of the root directory is
+ * place in. Browsing to directories outside the root directory is
* prevented by the widget. Note that this is not a security feature.
- * Whether remote directories are browseable or not solely depends on the
+ * Whether remote directories are browsable or not solely depends on the
* ACL setup for the current session.
*/
__init__: function(value, options) {
@@ -2783,13 +2883,8 @@ var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
rows = E('ul');
list.sort(function(a, b) {
- var isDirA = (a.type == 'directory'),
- isDirB = (b.type == 'directory');
-
- if (isDirA != isDirB)
- return isDirA < isDirB;
-
- return a.name > b.name;
+ return L.naturalCompare(a.type == 'directory', b.type == 'directory') ||
+ L.naturalCompare(a.name, b.name);
});
for (var i = 0; i < list.length; i++) {
@@ -2959,7 +3054,7 @@ function scrubMenu(node) {
for (var k in node.children) {
var child = scrubMenu(node.children[k]);
- if (child.title)
+ if (child.title && !child.firstchild_ineligible)
hasSatisfiedChild = hasSatisfiedChild || child.satisfied;
}
}
@@ -2990,7 +3085,7 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
* @property {string} name - The internal name of the node, as used in the URL
* @property {number} order - The sort index of the menu node
* @property {string} [title] - The title of the menu node, `null` if the node should be hidden
- * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
+ * @property {satisfied} boolean - Boolean indicating whether the menu entries dependencies are satisfied
* @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
* @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
*/
@@ -3049,7 +3144,24 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
if (!node.children[k].hasOwnProperty('title'))
continue;
- children.push(Object.assign(node.children[k], { name: k }));
+ var subnode = Object.assign(node.children[k], { name: k });
+
+ if (L.isObject(subnode.action) && subnode.action.path != null &&
+ (subnode.action.type == 'alias' || subnode.action.type == 'rewrite')) {
+ var root = this.menu,
+ path = subnode.action.path.split('/');
+
+ for (var i = 0; root != null && i < path.length; i++)
+ root = L.isObject(root.children) ? root.children[path[i]] : null;
+
+ if (root)
+ subnode = Object.assign({}, subnode, {
+ children: root.children,
+ action: root.action
+ });
+ }
+
+ children.push(subnode);
}
return children.sort(function(a, b) {
@@ -3059,8 +3171,313 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
if (wA != wB)
return wA - wB;
- return a.name > b.name;
+ return L.naturalCompare(a.name, b.name);
+ });
+ }
+});
+
+var UITable = baseclass.extend(/** @lends LuCI.ui.table.prototype */ {
+ __init__: function(captions, options, placeholder) {
+ if (!Array.isArray(captions)) {
+ this.initFromMarkup(captions);
+
+ return;
+ }
+
+ var id = options.id || 'table%08x'.format(Math.random() * 0xffffffff);
+
+ var table = E('table', { 'id': id, 'class': 'table' }, [
+ E('tr', { 'class': 'tr table-titles', 'click': UI.prototype.createHandlerFn(this, 'handleSort') })
+ ]);
+
+ this.id = id;
+ this.node = table
+ this.options = options;
+
+ var sorting = this.getActiveSortState();
+
+ for (var i = 0; i < captions.length; i++) {
+ if (captions[i] == null)
+ continue;
+
+ var th = E('th', { 'class': 'th' }, [ captions[i] ]);
+
+ if (typeof(options.captionClasses) == 'object')
+ DOMTokenList.prototype.add.apply(th.classList, L.toArray(options.captionClasses[i]));
+
+ if (options.sortable !== false && (typeof(options.sortable) != 'object' || options.sortable[i] !== false)) {
+ th.setAttribute('data-sortable-row', true);
+
+ if (sorting && sorting[0] == i)
+ th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc');
+ }
+
+ table.firstElementChild.appendChild(th);
+ }
+
+ if (placeholder) {
+ var trow = table.appendChild(E('tr', { 'class': 'tr placeholder' })),
+ td = trow.appendChild(E('td', { 'class': 'td' }, placeholder));
+
+ if (typeof(captionClasses) == 'object')
+ DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
+ }
+
+ DOMTokenList.prototype.add.apply(table.classList, L.toArray(options.classes));
+ },
+
+ update: function(data, placeholder) {
+ var placeholder = placeholder || this.options.placeholder || _('No data', 'empty table placeholder'),
+ sorting = this.getActiveSortState();
+
+ if (!Array.isArray(data))
+ return;
+
+ this.data = data;
+ this.placeholder = placeholder;
+
+ var n = 0,
+ rows = this.node.querySelectorAll('tr, .tr'),
+ trows = [],
+ headings = [].slice.call(this.node.firstElementChild.querySelectorAll('th, .th')),
+ captionClasses = this.options.captionClasses,
+ trTag = (rows[0] && rows[0].nodeName == 'DIV') ? 'div' : 'tr',
+ tdTag = (headings[0] && headings[0].nodeName == 'DIV') ? 'div' : 'td';
+
+ if (sorting) {
+ var list = data.map(L.bind(function(row) {
+ return [ this.deriveSortKey(row[sorting[0]], sorting[0]), row ];
+ }, this));
+
+ list.sort(function(a, b) {
+ return sorting[1]
+ ? -L.naturalCompare(a[0], b[0])
+ : L.naturalCompare(a[0], b[0]);
+ });
+
+ data.length = 0;
+
+ list.forEach(function(item) {
+ data.push(item[1]);
+ });
+
+ headings.forEach(function(th, i) {
+ if (i == sorting[0])
+ th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc');
+ else
+ th.removeAttribute('data-sort-direction');
+ });
+ }
+
+ data.forEach(function(row) {
+ trows[n] = E(trTag, { 'class': 'tr' });
+
+ for (var i = 0; i < headings.length; i++) {
+ var text = (headings[i].innerText || '').trim();
+ var raw_val = Array.isArray(row[i]) ? row[i][0] : null;
+ var disp_val = Array.isArray(row[i]) ? row[i][1] : row[i];
+ var td = trows[n].appendChild(E(tdTag, {
+ 'class': 'td',
+ 'data-title': (text !== '') ? text : null,
+ 'data-value': raw_val
+ }, (disp_val != null) ? ((disp_val instanceof DocumentFragment) ? disp_val.cloneNode(true) : disp_val) : ''));
+
+ if (typeof(captionClasses) == 'object')
+ DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[i]));
+
+ if (!td.classList.contains('cbi-section-actions'))
+ headings[i].setAttribute('data-sortable-row', true);
+ }
+
+ trows[n].classList.add('cbi-rowstyle-%d'.format((n++ % 2) ? 2 : 1));
});
+
+ for (var i = 0; i < n; i++) {
+ if (rows[i+1])
+ this.node.replaceChild(trows[i], rows[i+1]);
+ else
+ this.node.appendChild(trows[i]);
+ }
+
+ while (rows[++n])
+ this.node.removeChild(rows[n]);
+
+ if (placeholder && this.node.firstElementChild === this.node.lastElementChild) {
+ var trow = this.node.appendChild(E(trTag, { 'class': 'tr placeholder' })),
+ td = trow.appendChild(E(tdTag, { 'class': 'td' }, placeholder));
+
+ if (typeof(captionClasses) == 'object')
+ DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
+ }
+
+ return this.node;
+ },
+
+ render: function() {
+ return this.node;
+ },
+
+ /** @private */
+ initFromMarkup: function(node) {
+ if (!dom.elem(node))
+ node = document.querySelector(node);
+
+ if (!node)
+ throw 'Invalid table selector';
+
+ var options = {},
+ headrow = node.querySelector('tr, .tr');
+
+ if (!headrow)
+ return;
+
+ options.id = node.id;
+ options.classes = [].slice.call(node.classList).filter(function(c) { return c != 'table' });
+ options.sortable = [];
+ options.captionClasses = [];
+
+ headrow.querySelectorAll('th, .th').forEach(function(th, i) {
+ options.sortable[i] = !th.classList.contains('cbi-section-actions');
+ options.captionClasses[i] = [].slice.call(th.classList).filter(function(c) { return c != 'th' });
+ });
+
+ headrow.addEventListener('click', UI.prototype.createHandlerFn(this, 'handleSort'));
+
+ this.id = node.id;
+ this.node = node;
+ this.options = options;
+ },
+
+ /** @private */
+ deriveSortKey: function(value, index) {
+ var opts = this.options || {},
+ hint, m;
+
+ if (opts.sortable == true || opts.sortable == null)
+ hint = 'auto';
+ else if (typeof( opts.sortable) == 'object')
+ hint = opts.sortable[index];
+
+ if (dom.elem(value)) {
+ if (value.hasAttribute('data-value'))
+ value = value.getAttribute('data-value');
+ else
+ value = (value.innerText || '').trim();
+ }
+
+ switch (hint || 'auto') {
+ case true:
+ case 'auto':
+ m = /^([0-9a-fA-F:.]+)(?:\/([0-9a-fA-F:.]+))?$/.exec(value);
+
+ if (m) {
+ var addr, mask;
+
+ addr = validation.parseIPv6(m[1]);
+ mask = m[2] ? validation.parseIPv6(m[2]) : null;
+
+ if (addr && mask != null)
+ return '%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x'.format(
+ addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
+ mask[0], mask[1], mask[2], mask[3], mask[4], mask[5], mask[6], mask[7]
+ );
+ else if (addr)
+ return '%04x%04x%04x%04x%04x%04x%04x%04x%02x'.format(
+ addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
+ m[2] ? +m[2] : 128
+ );
+
+ addr = validation.parseIPv4(m[1]);
+ mask = m[2] ? validation.parseIPv4(m[2]) : null;
+
+ if (addr && mask != null)
+ return '%03d%03d%03d%03d%03d%03d%03d%03d'.format(
+ addr[0], addr[1], addr[2], addr[3],
+ mask[0], mask[1], mask[2], mask[3]
+ );
+ else if (addr)
+ return '%03d%03d%03d%03d%02d'.format(
+ addr[0], addr[1], addr[2], addr[3],
+ m[2] ? +m[2] : 32
+ );
+ }
+
+ m = /^(?:(\d+)d )?(\d+)h (\d+)m (\d+)s$/.exec(value);
+
+ if (m)
+ return '%05d%02d%02d%02d'.format(+m[1], +m[2], +m[3], +m[4]);
+
+ m = /^(\d+)\b(\D*)$/.exec(value);
+
+ if (m)
+ return '%010d%s'.format(+m[1], m[2]);
+
+ return String(value);
+
+ case 'ignorecase':
+ return String(value).toLowerCase();
+
+ case 'numeric':
+ return +value;
+
+ default:
+ return String(value);
+ }
+ },
+
+ /** @private */
+ getActiveSortState: function() {
+ if (this.sortState)
+ return this.sortState;
+
+ if (!this.options.id)
+ return null;
+
+ var page = document.body.getAttribute('data-page'),
+ key = page + '.' + this.options.id,
+ state = session.getLocalData('tablesort');
+
+ if (L.isObject(state) && Array.isArray(state[key]))
+ return state[key];
+
+ return null;
+ },
+
+ /** @private */
+ setActiveSortState: function(index, descending) {
+ this.sortState = [ index, descending ];
+
+ if (!this.options.id)
+ return;
+
+ var page = document.body.getAttribute('data-page'),
+ key = page + '.' + this.options.id,
+ state = session.getLocalData('tablesort');
+
+ if (!L.isObject(state))
+ state = {};
+
+ state[key] = this.sortState;
+
+ session.setLocalData('tablesort', state);
+ },
+
+ /** @private */
+ handleSort: function(ev) {
+ if (!ev.target.matches('th[data-sortable-row]'))
+ return;
+
+ var index, direction;
+
+ this.node.firstElementChild.querySelectorAll('th, .th').forEach(function(th, i) {
+ if (th === ev.target) {
+ index = i;
+ direction = th.getAttribute('data-sort-direction') == 'asc';
+ }
+ });
+
+ this.setActiveSortState(index, direction);
+ this.update(this.data, this.placeholder);
}
});
@@ -3077,8 +3494,17 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
__init__: function() {
modalDiv = document.body.appendChild(
- dom.create('div', { id: 'modal_overlay' },
- dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
+ dom.create('div', {
+ id: 'modal_overlay',
+ tabindex: -1,
+ keydown: this.cancelModal
+ }, [
+ dom.create('div', {
+ class: 'modal',
+ role: 'dialog',
+ 'aria-modal': true
+ })
+ ]));
tooltipDiv = document.body.appendChild(
dom.create('div', { class: 'cbi-tooltip' }));
@@ -3108,7 +3534,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* be opened. Invoking showModal() while a modal dialog is already open will
* replace the open dialog with a new one having the specified contents.
*
- * Additional CSS class names may be passed to influence the appearence of
+ * Additional CSS class names may be passed to influence the appearance of
* the dialog. Valid values for the classes depend on the underlying theme.
*
* @see LuCI.dom.content
@@ -3116,7 +3542,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* @param {string} [title]
* The title of the dialog. If `null`, no title element will be rendered.
*
- * @param {*} contents
+ * @param {*} children
* The contents to add to the modal dialog. This should be a DOM node or
* a document fragment in most cases. The value is passed as-is to the
* `dom.content()` function - refer to its documentation for applicable
@@ -3142,6 +3568,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
document.body.classList.add('modal-overlay-active');
modalDiv.scrollTop = 0;
+ modalDiv.focus();
return dlg;
},
@@ -3158,6 +3585,17 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
*/
hideModal: function() {
document.body.classList.remove('modal-overlay-active');
+ modalDiv.blur();
+ },
+
+ /** @private */
+ cancelModal: function(ev) {
+ if (ev.key == 'Escape') {
+ var btn = modalDiv.querySelector('.right > button, .right > .btn');
+
+ if (btn)
+ btn.click();
+ }
},
/** @private */
@@ -3174,7 +3612,8 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
var rect = target.getBoundingClientRect(),
x = rect.left + window.pageXOffset,
- y = rect.top + rect.height + window.pageYOffset;
+ y = rect.top + rect.height + window.pageYOffset,
+ above = false;
tooltipDiv.className = 'cbi-tooltip';
tooltipDiv.innerHTML = '▲ ';
@@ -3183,7 +3622,15 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
if (target.hasAttribute('data-tooltip-style'))
tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
- if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
+ if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset))
+ above = true;
+
+ var dropdown = target.querySelector('ul.dropdown[style]:first-child');
+
+ if (dropdown && dropdown.style.top)
+ above = true;
+
+ if (above) {
y -= (tooltipDiv.offsetHeight + target.offsetHeight);
tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
}
@@ -3219,11 +3666,11 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* Add a notification banner at the top of the current view.
*
* A notification banner is an alert message usually displayed at the
- * top of the current view, spanning the entire availibe width.
+ * top of the current view, spanning the entire available width.
* Notification banners will stay in place until dismissed by the user.
* Multiple banners may be shown at the same time.
*
- * Additional CSS class names may be passed to influence the appearence of
+ * Additional CSS class names may be passed to influence the appearance of
* the banner. Valid values for the classes depend on the underlying theme.
*
* @see LuCI.dom.content
@@ -3232,7 +3679,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* The title of the notification banner. If `null`, no title element
* will be rendered.
*
- * @param {*} contents
+ * @param {*} children
* The contents to add to the notification banner. This should be a DOM
* node or a document fragment in most cases. The value is passed as-is
* to the `dom.content()` function - refer to its documentation for
@@ -3283,7 +3730,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
},
/**
- * Display or update an header area indicator.
+ * Display or update a header area indicator.
*
* An indicator is a small label displayed in the header area of the screen
* providing few amounts of status information such as item counts or state
@@ -3310,7 +3757,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* Note that this parameter only applies to new indicators, when updating
* existing labels it is ignored.
*
- * @param {string} [style=active]
+ * @param {"active"|"inactive"} [style=active]
* The indicator style to use. May be either `active` or `inactive`.
*
* @returns {boolean}
@@ -3353,7 +3800,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
},
/**
- * Remove an header area indicator.
+ * Remove a header area indicator.
*
* This function removes the given indicator label from the header indicator
* area. When the given indicator is not found, this function does nothing.
@@ -3387,7 +3834,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* subsequently wrapped into a `<span class="nowrap">` element.
*
* The resulting `<span>` element tuples are joined by the given separators
- * to form the final markup which is appened to the given parent DOM node.
+ * to form the final markup which is appended to the given parent DOM node.
*
* @param {Node} node
* The parent DOM node to append the markup to. Any previous child elements
@@ -3556,9 +4003,11 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
this.setActiveTabId(panes[selected], selected);
}
- panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
- detail: { tab: panes[selected].getAttribute('data-tab') }
- }));
+ requestAnimationFrame(L.bind(function(pane) {
+ pane.dispatchEvent(new CustomEvent('cbi-tab-active', {
+ detail: { tab: pane.getAttribute('data-tab') }
+ }));
+ }, this, panes[selected]));
this.updateTabs(group);
},
@@ -3712,7 +4161,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* @param {string} path
* The remote file path to upload the local file to.
*
- * @param {Node} [progessStatusNode]
+ * @param {Node} [progressStatusNode]
* An optional DOM text node whose content text is set to the progress
* percentage value during file upload.
*
@@ -3762,7 +4211,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
'class': 'btn',
'click': function() {
UI.prototype.hideModal();
- rejectFn(new Error('Upload has been cancelled'));
+ rejectFn(new Error(_('Upload has been cancelled')));
}
}, [ _('Cancel') ]),
' ',
@@ -3826,7 +4275,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
/**
* Perform a device connectivity test.
*
- * Attempt to fetch a well known ressource from the remote device via HTTP
+ * Attempt to fetch a well known resource from the remote device via HTTP
* in order to test connectivity. This function is mainly useful to wait
* for the router to come back online after a reboot or reconfiguration.
*
@@ -3834,7 +4283,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
* The protocol to use for fetching the resource. May be either `http`
* (the default) or `https`.
*
- * @param {string} [host=window.location.host]
+ * @param {string} [ipaddr=window.location.host]
* Override the host address to probe. By default the current host as seen
* in the address bar is probed.
*
@@ -3923,7 +4372,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
*
* @instance
* @memberof LuCI.ui.changes
- * @param {number} numChanges
+ * @param {number} n
* The number of changes to indicate.
*/
setIndicator: function(n) {
@@ -4001,11 +4450,17 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
E('button', {
'class': 'btn',
'click': UI.prototype.hideModal
- }, [ _('Dismiss') ]), ' ',
- E('button', {
- 'class': 'cbi-button cbi-button-positive important',
- 'click': L.bind(this.apply, this, true)
- }, [ _('Save & Apply') ]), ' ',
+ }, [ _('Close') ]), ' ',
+ new UIComboButton('0', {
+ 0: [ _('Save & Apply') ],
+ 1: [ _('Apply unchecked') ]
+ }, {
+ classes: {
+ 0: 'btn cbi-button cbi-button-positive important',
+ 1: 'btn cbi-button cbi-button-negative important'
+ },
+ click: L.bind(function(ev, mode) { this.apply(mode == '0') }, this)
+ }).render(), ' ',
E('button', {
'class': 'cbi-button cbi-button-reset',
'click': L.bind(this.revert, this)
@@ -4075,6 +4530,26 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
},
/** @private */
+ checkConnectivityAffected: function() {
+ return L.resolveDefault(fs.exec_direct('/usr/libexec/luci-peeraddr', null, 'json')).then(L.bind(function(info) {
+ if (L.isObject(info) && Array.isArray(info.inbound_interfaces)) {
+ for (var i = 0; i < info.inbound_interfaces.length; i++) {
+ var iif = info.inbound_interfaces[i];
+
+ for (var j = 0; this.changes && this.changes.network && j < this.changes.network.length; j++) {
+ var chg = this.changes.network[j];
+
+ if (chg[0] == 'set' && chg[1] == iif && (chg[2] == 'proto' || chg[2] == 'ipaddr' || chg[2] == 'netmask'))
+ return iif;
+ }
+ }
+ }
+
+ return null;
+ }, this));
+ },
+
+ /** @private */
rollback: function(checked) {
if (checked) {
this.displayStatus('warning spinning',
@@ -4111,7 +4586,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
method: 'post',
timeout: L.env.apply_timeout * 1000,
query: { sid: L.env.sessionid, token: L.env.token }
- }).then(call);
+ }).then(call, call.bind(null, { status: 0 }, null, 0));
}, delay);
};
@@ -4211,35 +4686,65 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
this.displayStatus('notice spinning',
E('p', _('Starting configuration apply…')));
- request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
- method: 'post',
- query: { sid: L.env.sessionid, token: L.env.token }
- }).then(function(r) {
- if (r.status === (checked ? 200 : 204)) {
- var tok = null; try { tok = r.json(); } catch(e) {}
- if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
- UI.prototype.changes.confirm_auth = tok;
-
- UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
- }
- else if (checked && r.status === 204) {
- UI.prototype.changes.displayStatus('notice',
- E('p', _('There are no changes to apply')));
+ (new Promise(function(resolveFn, rejectFn) {
+ if (!checked)
+ return resolveFn(false);
+
+ UI.prototype.changes.checkConnectivityAffected().then(function(affected) {
+ if (!affected)
+ return resolveFn(true);
+
+ UI.prototype.changes.displayStatus('warning', [
+ E('h4', _('Connectivity change')),
+ E('p', _('The network access to this device could be interrupted by changing settings of the "%h" interface.').format(affected)),
+ E('p', _('If the IP address used to access LuCI changes, a <strong>manual reconnect to the new IP</strong> is required within %d seconds to confirm the settings, otherwise modifications will be reverted.').format(L.env.apply_rollback)),
+ E('div', { 'class': 'right' }, [
+ E('button', {
+ 'class': 'btn',
+ 'click': rejectFn,
+ }, [ _('Cancel') ]), ' ',
+ E('button', {
+ 'class': 'btn cbi-button-action important',
+ 'click': resolveFn.bind(null, true)
+ }, [ _('Apply with revert after connectivity loss') ]), ' ',
+ E('button', {
+ 'class': 'btn cbi-button-negative important',
+ 'click': resolveFn.bind(null, false)
+ }, [ _('Apply and keep settings') ])
+ ])
+ ]);
+ });
+ })).then(function(checked) {
+ request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
+ method: 'post',
+ query: { sid: L.env.sessionid, token: L.env.token }
+ }).then(function(r) {
+ if (r.status === (checked ? 200 : 204)) {
+ var tok = null; try { tok = r.json(); } catch(e) {}
+ if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
+ UI.prototype.changes.confirm_auth = tok;
+
+ UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
+ }
+ else if (checked && r.status === 204) {
+ UI.prototype.changes.displayStatus('notice',
+ E('p', _('There are no changes to apply')));
- window.setTimeout(function() {
- UI.prototype.changes.displayStatus(false);
- }, L.env.apply_display * 1000);
- }
- else {
- UI.prototype.changes.displayStatus('warning',
- E('p', _('Apply request failed with status <code>%h</code>')
- .format(r.responseText || r.statusText || r.status)));
+ window.setTimeout(function() {
+ UI.prototype.changes.displayStatus(false);
+ }, L.env.apply_display * 1000);
+ }
+ else {
+ UI.prototype.changes.displayStatus('warning',
+ E('p', _('Apply request failed with status <code>%h</code>')
+ .format(r.responseText || r.statusText || r.status)));
- window.setTimeout(function() {
- UI.prototype.changes.displayStatus(false);
- }, L.env.apply_display * 1000);
- }
- });
+ window.setTimeout(function() {
+ UI.prototype.changes.displayStatus(false);
+ }, L.env.apply_display * 1000);
+ }
+ });
+ }, this.displayStatus.bind(this, false));
},
/**
@@ -4408,7 +4913,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
*
* By instantiating the view class, its corresponding contents are
* rendered and included into the view area. Any runtime errors are
- * catched and rendered using [LuCI.error()]{@link LuCI#error}.
+ * caught and rendered using [LuCI.error()]{@link LuCI#error}.
*
* @param {string} path
* The view path to render.
@@ -4432,6 +4937,8 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
menu: UIMenu,
+ Table: UITable,
+
AbstractElement: UIElement,
/* Widgets */