diff options
Diffstat (limited to 'modules/luci-base/htdocs/luci-static/resources/ui.js')
-rw-r--r-- | modules/luci-base/htdocs/luci-static/resources/ui.js | 777 |
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 */ |