From 2da63d766a765ecae80a1d0a9849fb5f8c5aebb5 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Tue, 3 Feb 2026 06:42:15 +0100 Subject: [PATCH] luci-base: refresh table generation Tables are now structured with standard HTML tags: table -> thead -> tr rows table -> tbody -> tr rows table -> tfoot -> tr rows - wrap table header rows in a thead element - wrap table body rows in a tbody element - wrap footer rowss in a tfoot element Footer row data can be provided by initializing any of the form table types with .footer set to a string or function, or overriding the renderFooterRows method. Signed-off-by: Paul Donald --- .../htdocs/luci-static/resources/form.js | 85 ++++++++++++++++++- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index b2a0ca5f7a..f5e67078d2 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -2545,6 +2545,24 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection * @default null */ + /** + * Optional footer row for table sections. + * + * Set `footer` to one of: + * - a function that returns a table row (`tr`) or node `E('...')` + * - an array of string cell contents (first entry maps to the name column + * if present). + * + * This is useful for providing sum totals, extra function buttons or extra + * space. + * + * The default implementation returns an empty node. + * + * @name LuCI.form.TableSection.prototype#footer + * @type string[]|function + * @default E([]) + */ + /** * Set to `true`, a sort button is added to the last column, allowing * the user to reorder the section instances mapped by the section form @@ -2611,13 +2629,28 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection 'class': 'table cbi-section-table' }); + const theadEl = E('thead', { + 'class': 'thead cbi-section-thead' + }); + + const tbodyEl = E('tbody', { + 'class': 'tbody cbi-section-tbody' + }); + + const tfootEl = E('tfoot', { + 'class': 'tfoot cbi-section-tfoot' + }); + if (this.title != null && this.title != '' && !this.hidetitle) sectionEl.appendChild(E('h3', {}, this.title)); if (this.description != null && this.description != '') sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description)); - tableEl.appendChild(this.renderHeaderRows(false)); + theadEl.appendChild(this.renderHeaderRows(false)); + + if(theadEl.hasChildNodes()) + tableEl.appendChild(theadEl); for (let i = 0; i < nodes.length; i++) { let sectionname = this.titleFn('sectiontitle', cfgsections[i]); @@ -2640,7 +2673,7 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection }); if (this.extedit || this.rowcolors) - trEl.classList.add(!(tableEl.childNodes.length % 2) + trEl.classList.add(!(tbodyEl.childNodes.length % 2) ? 'cbi-rowstyle-1' : 'cbi-rowstyle-2'); if (sectionname && (!this.anonymous || this.sectiontitle)) { @@ -2653,13 +2686,20 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection trEl.appendChild(nodes[i].firstChild); trEl.appendChild(this.renderRowActions(cfgsections[i], has_more ? _('More…') : null)); - tableEl.appendChild(trEl); + tbodyEl.appendChild(trEl); } if (nodes.length == 0) - tableEl.appendChild(E('tr', { 'class': 'tr cbi-section-table-row placeholder' }, + tbodyEl.appendChild(E('tr', { 'class': 'tr cbi-section-table-row placeholder' }, E('td', { 'class': 'td' }, this.renderSectionPlaceholder()))); + tableEl.appendChild(tbodyEl); + + tfootEl.appendChild(this.renderFooterRows(false)); + + if (tfootEl.hasChildNodes()) + tableEl.appendChild(tfootEl); + sectionEl.appendChild(tableEl); sectionEl.appendChild(this.renderSectionAdd('cbi-tblsection-create')); @@ -2768,6 +2808,43 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection return trEls; }, + /** @private */ + renderFooterRows(has_action) { + if (this.footer == null) + return E([]); + + const max_cols = this.max_cols ?? this.children.length; + const has_more = max_cols < this.children.length; + const anon_class = (!this.anonymous || this.sectiontitle) ? 'named' : 'anonymous'; + + if (typeof this.footer === 'function') { + const node = this.footer.call(this, has_action); + return node || E([]); + } + + const values = Array.isArray(this.footer) ? this.footer : []; + let idx = 0; + const trEl = E('tr', { 'class': `tr cbi-section-table-footer ${anon_class}` }); + + if (!this.anonymous || this.sectiontitle) { + trEl.appendChild(E('td', { 'class': 'td cbi-value-field cbi-section-table-titles' }, values[idx++] ?? null)); + } + + for (let i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.modalonly) + continue; + + trEl.appendChild(E('td', { 'class': 'td', 'data-widget': opt.__name__ }, values[idx++] ?? null)); + } + + if (this.sortable || this.extedit || this.addremove || has_more || has_action || this.cloneable) { + trEl.appendChild(E('td', { 'class': 'td cbi-section-actions' }, values[idx++] ?? null)); + } + + return trEl; + }, + + /** @private */ renderRowActions(section_id, more_label, trEl) { const config_name = this.uciconfig ?? this.map.config;