luci-base: refresh table generation
authorPaul Donald <newtwen+github@gmail.com>
Tue, 3 Feb 2026 05:42:15 +0000 (06:42 +0100)
committerPaul Donald <newtwen+github@gmail.com>
Tue, 3 Feb 2026 05:42:15 +0000 (06:42 +0100)
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 <newtwen+github@gmail.com>
modules/luci-base/htdocs/luci-static/resources/form.js

index b2a0ca5f7a3b859fce1469739d0c46fb68ff7ecc..f5e67078d23bbd21030a7f948ad06cdf7a25549b 100644 (file)
@@ -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;