diff --git a/public/demo/nav.js b/public/demo/nav.js
index e239af0..40c0b63 100644
--- a/public/demo/nav.js
+++ b/public/demo/nav.js
@@ -27,7 +27,8 @@
{ path: 'find-in-table/index.html', label: '12. Find in table' },
{ path: 'matrix/index.html', label: '13. Matrix fixture' },
{ path: 'copy-as-csv/index.html', label: '14. Copy as CSV' },
- { path: 'twin-table/index.html', label: '15. Twin table (investigation)' }
+ { path: 'twin-table/index.html', label: '15. Twin table (investigation)' },
+ { path: 'sliders/merged-headers.html', label: '16. Merged headers' }
];
var STYLE_ID = 'gs-demo-nav-styles';
diff --git a/public/demo/sliders/merged-headers.html b/public/demo/sliders/merged-headers.html
new file mode 100644
index 0000000..0ff3130
--- /dev/null
+++ b/public/demo/sliders/merged-headers.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+ Grid-Sight · Slider · Merged / banner headers
+
+
+
+
+
+ Slider · Merged / banner headers
+
+ Speed-vs-length matrix tables often carry a merged “Length Overall (m)”
+ banner above the numeric column headers. Grid-Sight peels off the banner and
+ binds interpolation to the leaf header row, so both author
+ permutations below offer the sliders. Click the GS toggle,
+ open the + on the top-left corner cell, choose
+ Sliders , then drag either axis to interpolate — the
+ bracketing four cells highlight live.
+
+
+ Permutation 1 — full-width colspan banner
+
+ A single merged cell spans the whole header; the leaf row still carries the
+ Speed Knots corner alongside the length headers.
+
+
+
+ Permutation 2 — rowspan corner + colspan banner
+
+ Speed Knots spans both header rows, so the leaf row is
+ all length headers (no corner cell) with its cells shifted right
+ under the rowspan. This is the case that previously blocked interpolation.
+
+
+
+
+
+
+
+
diff --git a/public/index.html b/public/index.html
index e9eb98c..ee18945 100644
--- a/public/index.html
+++ b/public/index.html
@@ -223,6 +223,7 @@ Read between the rows
Interpolation demo →
Calc models →
+ Merged headers →
@@ -399,6 +400,10 @@
Alternate calc models
Persistent URL
Slider positions encoded in the URL fragment — bookmark, share, or reload at a value.
+
+ Merged headers
+ Interpolation on a speed-vs-length matrix with a merged banner header — colspan banner and rowspan corner.
+
Live toggles — all on
Pre-ticked enrichment panel: untick any feature and its lozenges vanish within a frame.
diff --git a/src/core/__tests__/table-grid.test.ts b/src/core/__tests__/table-grid.test.ts
index abef3bf..95e5936 100644
--- a/src/core/__tests__/table-grid.test.ts
+++ b/src/core/__tests__/table-grid.test.ts
@@ -15,6 +15,7 @@ import {
isVirtualColumn,
gridRows,
headerRow,
+ headerRows,
bodyRows,
gridCells,
sourceCells,
@@ -23,6 +24,7 @@ import {
cellAt,
columnCells,
headerCellFor,
+ dataHeaderCells,
logicalColIndexOf,
logicalRowIndexOf,
cellValue,
@@ -124,6 +126,83 @@ describe('row access', () => {
});
});
+/* ── Merged / banner headers ────────────────────────────────────────── */
+
+describe('merged banner headers', () => {
+ it('picks the leaf header row past a full-width banner (permutation 1)', () => {
+ const table = document.createElement('table');
+ table.innerHTML = `
+
+ Length Overall (m)
+ Speed Knots 10 20 30
+
+
+ 10 1 2 3
+ 20 4 5 6
+
+ `;
+ document.body.appendChild(table);
+ expect(headerRow(table)!.cells[0].textContent).toBe('Speed Knots');
+ expect(headerRows(table)).toHaveLength(2);
+ expect(bodyRows(table)).toHaveLength(2);
+ // Column headers drop the corner label, keep the numeric leaf headers.
+ expect(dataHeaderCells(table).map((c) => c.textContent)).toEqual([
+ '10',
+ '20',
+ '30',
+ ]);
+ });
+
+ it('resolves a rowspan corner + banner: leaf cells are shifted right (permutation 2)', () => {
+ const table = document.createElement('table');
+ table.innerHTML = `
+
+ Speed Knots Length Overall (m)
+ 10 20 30
+
+
+ 10 1 2 3
+ 20 4 5 6
+
+ `;
+ document.body.appendChild(table);
+ // Leaf row is the numeric header row; it has NO corner cell of its own.
+ expect(headerRow(table)!.cells[0].textContent).toBe('10');
+ expect(headerRows(table)).toHaveLength(2);
+ // Occupancy-aware: none of the three leaf cells is dropped as a row label.
+ expect(dataHeaderCells(table).map((c) => c.textContent)).toEqual([
+ '10',
+ '20',
+ '30',
+ ]);
+ });
+
+ it('leaves a plain single-row header unchanged (dataHeaderCells == slice(1))', () => {
+ const { table } = buildNumericGrid();
+ const head = headerRow(table)!;
+ expect(headerRows(table)).toHaveLength(1);
+ expect(dataHeaderCells(table)).toEqual(sourceCells(head).slice(1));
+ });
+
+ it('skips a no- leading banner row', () => {
+ const table = document.createElement('table');
+ table.innerHTML = `
+ Length Overall (m)
+ Speed Knots 10 20 30
+ 10 1 2 3
+ 20 4 5 6
+ `;
+ document.body.appendChild(table);
+ expect(headerRow(table)!.cells[1].textContent).toBe('10');
+ expect(bodyRows(table)).toHaveLength(2);
+ expect(dataHeaderCells(table).map((c) => c.textContent)).toEqual([
+ '10',
+ '20',
+ '30',
+ ]);
+ });
+});
+
/* ── Cells + counts ─────────────────────────────────────────────────── */
describe('cell views and counts', () => {
diff --git a/src/core/table-grid.ts b/src/core/table-grid.ts
index e848249..985b540 100644
--- a/src/core/table-grid.ts
+++ b/src/core/table-grid.ts
@@ -75,7 +75,27 @@ function footerRowSet(table: HTMLTableElement): Set {
}
/**
- * Partition the non-scaffold rows into header + body.
+ * True when `row` is a merged "banner"/grouping super-header sitting above the
+ * real (leaf) column-header row: it has fewer logical cells than the row below
+ * it AND at least one of its cells spans multiple columns. The classic cases are
+ * a single `` title (e.g. "Length Overall (m)") drawn above the
+ * numeric column headers, and a corner label that spans down with `rowspan`. The
+ * colspan requirement guards against mistaking a genuinely short header/data row
+ * for a banner. `next` is the row immediately following, measured with the same
+ * source-cell rule so both counts are comparable.
+ */
+function isBannerRow(
+ row: HTMLTableRowElement,
+ next: HTMLTableRowElement,
+): boolean {
+ const cells = sourceCells(row);
+ if (cells.length >= sourceCells(next).length) return false;
+ return cells.some((c) => Math.max(1, c.colSpan || 1) > 1);
+}
+
+/**
+ * Resolve the header region into leading banner/grouping rows, the leaf
+ * column-header row, and the body rows.
*
* Mirrors the header-detection rule in
* `src/utils/original-order.ts::getDataRows` (header is `table.rows[0]` iff it
@@ -85,8 +105,15 @@ function footerRowSet(table: HTMLTableElement): Set {
* a `` corner ahead of the real header, so running the heuristic over the
* raw rows (as `getDataRows` does) would mistake that scaffold row for the
* header. We mirror rather than reuse `getDataRows` for exactly this reason.
+ *
+ * Leading merged banner/grouping rows (a full-width ` ` title, or
+ * a `rowspan` corner + colspan banner above the numeric column headers) are
+ * peeled off so `header` is the LEAF column-header row that aligns 1:1 with the
+ * data columns. That is what lets sliders/interpolation bind to the numeric
+ * column headers rather than to the banner (see `isBannerRow`, `dataHeaderCells`).
*/
-function partitionRows(table: HTMLTableElement): {
+function resolveHeader(table: HTMLTableElement): {
+ banners: HTMLTableRowElement[];
header: HTMLTableRowElement | null;
body: HTMLTableRowElement[];
} {
@@ -100,27 +127,138 @@ function partitionRows(table: HTMLTableElement): {
: [];
if (theadRows.length > 0) {
- // Explicit head: header = first head row; body = all tbody data rows.
- return { header: theadRows[0], body: tbodyRows };
+ // Explicit head: the leaf header is the LAST head row; any earlier rows are
+ // banner/grouping rows. body = all tbody data rows.
+ const leaf = theadRows.length - 1;
+ return {
+ banners: theadRows.slice(0, leaf),
+ header: theadRows[leaf],
+ body: tbodyRows,
+ };
}
- // No : the de-facto header is the first tbody row iff it has a .
- if (
- tbodyRows.length > 0 &&
- Array.from(tbodyRows[0].cells).some((c) => c.tagName === 'TH')
+ // No : peel off leading merged banner rows, then the de-facto header is
+ // the first remaining row iff it has a .
+ let start = 0;
+ while (
+ start + 1 < tbodyRows.length &&
+ isBannerRow(tbodyRows[start], tbodyRows[start + 1])
) {
- return { header: tbodyRows[0], body: tbodyRows.slice(1) };
+ start++;
}
- return { header: tbodyRows[0] ?? null, body: tbodyRows };
+ const first = tbodyRows[start];
+ if (first && Array.from(first.cells).some((c) => c.tagName === 'TH')) {
+ return {
+ banners: tbodyRows.slice(0, start),
+ header: first,
+ body: tbodyRows.slice(start + 1),
+ };
+ }
+ return { banners: [], header: tbodyRows[0] ?? null, body: tbodyRows };
}
-/** The header row (first non-scaffold row; reuses original-order header rule). */
+/** The (leaf) header row: the row whose cells align 1:1 with the data columns.
+ * Reuses the original-order header rule and peels off any merged banner rows. */
export function headerRow(table: HTMLTableElement): HTMLTableRowElement | null {
- return partitionRows(table).header;
+ return resolveHeader(table).header;
+}
+
+/** The full header block — leading banner/grouping rows followed by the leaf
+ * column-header row — top-to-bottom, non-scaffold. Single-row headers return a
+ * one-element array. */
+export function headerRows(table: HTMLTableElement): HTMLTableRowElement[] {
+ const { banners, header } = resolveHeader(table);
+ return header ? [...banners, header] : [];
}
/** Non-scaffold data rows after the header, excluding . Dimmed rows kept. */
export function bodyRows(table: HTMLTableElement): HTMLTableRowElement[] {
- return partitionRows(table).body;
+ return resolveHeader(table).body;
+}
+
+/**
+ * Occupancy-aware sweep of the header block (leading banner/grouping rows + the
+ * leaf column-header row), honouring both colspan and rowspan. Returns, for each
+ * logical SOURCE column, the header cell covering it AT the leaf row — which is
+ * the leaf cell itself, or a corner label that spans DOWN into the leaf row from
+ * a banner row (e.g. `Speed Knots `). Also reports which
+ * leaf-row cell (if any) originates each logical column, so the row-label
+ * column can be told apart from the data-column headers.
+ */
+function sweepHeaderColumns(table: HTMLTableElement): {
+ perColumn: HTMLTableCellElement[];
+ leafOriginCols: Set;
+} {
+ const rows = headerRows(table);
+ if (rows.length === 0) return { perColumn: [], leafOriginCols: new Set() };
+ const leafIdx = rows.length - 1;
+ // grid[r][c] = the cell covering logical column c of header row r (from that
+ // row, or a rowspan spilling down from above).
+ const grid: (HTMLTableCellElement | undefined)[][] = rows.map(() => []);
+ const leafOriginCols = new Set();
+ for (let r = 0; r < rows.length; r++) {
+ let c = 0;
+ for (const cell of sourceCells(rows[r])) {
+ while (grid[r][c]) c++;
+ const cs = Math.max(1, cell.colSpan || 1);
+ const rs = Math.max(1, cell.rowSpan || 1);
+ if (r === leafIdx) leafOriginCols.add(c);
+ for (let dr = 0; dr < rs && r + dr < rows.length; dr++) {
+ for (let dc = 0; dc < cs; dc++) grid[r + dr][c + dc] = cell;
+ }
+ c += cs;
+ }
+ }
+ const leaf = grid[leafIdx];
+ const perColumn: HTMLTableCellElement[] = [];
+ for (let c = 0; c < leaf.length; c++) if (leaf[c]) perColumn.push(leaf[c]!);
+ return { perColumn, leafOriginCols };
+}
+
+/**
+ * The header cell for every logical SOURCE column, in order — the row-label
+ * corner first, then one per data column. Occupancy-aware over a multi-row
+ * (banner) header. For a plain single-row header this equals `sourceCells`
+ * expanded by colspan; callers that need the untouched single-row behaviour
+ * should gate on `headerRows(table).length > 1`.
+ */
+export function sourceHeaderColumns(
+ table: HTMLTableElement,
+): HTMLTableCellElement[] {
+ return sweepHeaderColumns(table).perColumn;
+}
+
+/**
+ * The leaf header cells that head the DATA columns — every source cell of the
+ * leaf header row except the one in the leading row-label column.
+ *
+ * Occupancy-aware over the whole header block (colspan + rowspan): a corner
+ * label that spans DOWN into the leaf row from a banner row (e.g. a
+ * `Speed Knots ` beside a `` banner) shifts
+ * the leaf row's cells to the right, so its first physical cell is correctly
+ * recognised as a data-column header rather than dropped as the row label. For a
+ * plain single-row header this is exactly `sourceCells(header).slice(1)`.
+ */
+export function dataHeaderCells(
+ table: HTMLTableElement,
+): HTMLTableCellElement[] {
+ const { perColumn, leafOriginCols } = sweepHeaderColumns(table);
+ // Logical column 0 is the row-label column. Keep the leaf-originated cells to
+ // its right (a corner that spans down from a banner row is not a leaf cell, so
+ // it is excluded automatically).
+ return perColumn.filter((_, col) => col >= 1 && leafOriginCols.has(col));
+}
+
+/**
+ * Author-text matrix aligned to logical SOURCE columns, for column-type
+ * detection / suitability on a table with a multi-row (banner) header. Row 0 is
+ * the leaf header aligned by logical column (occupancy-aware, so a rowspan
+ * corner keeps the numeric leaf headers in their true columns); the remaining
+ * rows are the body. Virtual columns are excluded.
+ */
+export function sourceColumnMatrix(table: HTMLTableElement): string[][] {
+ const header = sourceHeaderColumns(table).map(cellValue);
+ const body = bodyRows(table).map((row) => sourceCells(row).map(cellValue));
+ return [header, ...body];
}
/* ── Cells within a row ─────────────────────────────────────────────── */
diff --git a/src/core/type-detection.ts b/src/core/type-detection.ts
index 9c2c803..bd2b466 100644
--- a/src/core/type-detection.ts
+++ b/src/core/type-detection.ts
@@ -1,4 +1,10 @@
-import { gridRows, sourceCells, cellValue } from './table-grid';
+import {
+ gridRows,
+ sourceCells,
+ cellValue,
+ headerRows,
+ sourceColumnMatrix,
+} from './table-grid';
/**
* Type definitions for column type detection
@@ -227,6 +233,10 @@ export function analyzeTable(
*/
export function extractTableData(table: HTMLTableElement): string[][] {
if (!table.rows) return [];
+ // A multi-row (banner) header must be flattened by logical column so a merged
+ // banner row / rowspan corner doesn't collapse the column count and hide the
+ // table's numeric columns from type detection.
+ if (headerRows(table).length > 1) return sourceColumnMatrix(table);
// Read author source cells via the canonical addressing layer: scaffold rows
// and virtual columns are excluded and injected UI is stripped, so column-type
// detection sees only author data regardless of active enrichments (013).
diff --git a/src/enrichments/__tests__/slider.test.ts b/src/enrichments/__tests__/slider.test.ts
index b43ae15..773d59c 100644
--- a/src/enrichments/__tests__/slider.test.ts
+++ b/src/enrichments/__tests__/slider.test.ts
@@ -64,6 +64,95 @@ describe('buildAxisBinding', () => {
document.body.appendChild(tbl);
expect(buildAxisBinding(tbl, 'col')).toBeNull();
});
+
+ it('binds the column axis past a full-width merged banner row (permutation 1)', () => {
+ // A single merged banner cell drawn above the numeric column headers; the
+ // leaf header row still carries the "Speed Knots" corner + length headers.
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
+ Length Overall (m)
+ Speed Knots 10 20 30
+
+
+ 10 1 2 3
+ 20 4 5 6
+ 30 7 8 9
+
+ `;
+ document.body.appendChild(tbl);
+ const col = buildAxisBinding(tbl, 'col');
+ expect(col).not.toBeNull();
+ expect(col!.headerValues).toEqual([10, 20, 30]);
+ const row = buildAxisBinding(tbl, 'row');
+ expect(row).not.toBeNull();
+ expect(row!.headerValues).toEqual([10, 20, 30]);
+ });
+
+ it('binds the column axis with a rowspan corner + banner row (permutation 2)', () => {
+ // "Speed Knots" spans both header rows; the leaf row is ALL length headers
+ // (no corner cell to drop) and its cells are shifted right by the rowspan.
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
+ Speed Knots Length Overall (m)
+ 10 20 30
+
+
+ 10 1 2 3
+ 20 4 5 6
+ 30 7 8 9
+
+ `;
+ document.body.appendChild(tbl);
+ const col = buildAxisBinding(tbl, 'col');
+ expect(col).not.toBeNull();
+ expect(col!.headerValues).toEqual([10, 20, 30]);
+ const row = buildAxisBinding(tbl, 'row');
+ expect(row).not.toBeNull();
+ expect(row!.headerValues).toEqual([10, 20, 30]);
+ });
+});
+
+describe('sliders on merged-header tables', () => {
+ function bannerTable(): HTMLTableElement {
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
+ Speed Knots Length Overall (m)
+ 10 20 30
+
+
+ 10 1 2 3
+ 20 4 5 6
+ 30 7 8 9
+
+ `;
+ document.body.appendChild(tbl);
+ return tbl;
+ }
+
+ it('creates both axis sliders and interpolates on a rowspan-corner table', () => {
+ const tbl = bannerTable();
+ const rowCount = tbl.rows.length;
+ const rowSlider = addSlider(tbl, 'row');
+ const colSlider = addSlider(tbl, 'col');
+ expect(getSliders(tbl).length).toBe(2);
+ // Injection added a col-slider row and per-row/header slots.
+ expect(tbl.querySelectorAll('[data-gs-injected]').length).toBeGreaterThan(0);
+ rowSlider.setPosition(15); // between speeds 10 and 20
+ colSlider.setPosition(25); // between lengths 20 and 30
+ const readout = colSlider.handle.readoutInterpolated.textContent;
+ expect(readout).not.toBe('—');
+ expect(readout).not.toBe('');
+ removeAllSliders(tbl);
+ expect(getSliders(tbl).length).toBe(0);
+ // Injection tears down cleanly even though the row-slider header cell was
+ // inserted into the banner row spanning the whole header block: no injected
+ // scaffolding remains and the original row count is restored.
+ expect(tbl.querySelectorAll('[data-gs-injected]').length).toBe(0);
+ expect(tbl.rows.length).toBe(rowCount);
+ });
});
describe('addSlider', () => {
diff --git a/src/enrichments/slider-injection.ts b/src/enrichments/slider-injection.ts
index 7718936..da862f1 100644
--- a/src/enrichments/slider-injection.ts
+++ b/src/enrichments/slider-injection.ts
@@ -13,8 +13,9 @@ import type { EquationSnapshot } from './equation-panel';
import {
gridRows,
bodyRows,
- headerRow as gridHeaderRow,
+ headerRows as gridHeaderRows,
sourceCells,
+ dataHeaderCells,
cellValue,
} from '../core/table-grid';
@@ -68,9 +69,10 @@ function cellText(cell: HTMLTableCellElement): string {
export function readRawAxisHeaders(table: HTMLTableElement, axis: Axis): string[] {
if (axis === 'col') {
- const header = gridHeaderRow(table);
- if (!header) return [];
- return sourceCells(header).slice(1).map(cellText);
+ // Occupancy-aware read of the leaf column headers: robust to a merged
+ // banner row above the numeric headers and to a rowspan corner label that
+ // shifts the leaf cells right (spec: merged-header interpolation).
+ return dataHeaderCells(table).map(cellText);
}
// axis === 'row'
return bodyRows(table)
@@ -237,10 +239,13 @@ export function ensureTopRow(ctx: TableContext): HTMLTableRowElement {
return tr;
}
-function buildRowHeaderCell(): HTMLTableCellElement {
+function buildRowHeaderCell(headerRowSpan: number): HTMLTableCellElement {
const headerCell = document.createElement('th');
headerCell.setAttribute('data-gs-injected', '');
headerCell.setAttribute('data-gs-row-header', '');
+ // Span the whole header block so the injected gutter column lines up with the
+ // body when the header has a merged banner row above the leaf headers.
+ headerCell.rowSpan = Math.max(1, headerRowSpan);
headerCell.style.padding = '6px';
headerCell.style.verticalAlign = 'middle';
headerCell.style.textAlign = 'center';
@@ -262,10 +267,13 @@ function buildRowSliderCell(rowSpan: number): HTMLTableCellElement {
export function ensureRowSliderSlot(ctx: TableContext): HTMLTableCellElement {
if (ctx.rowSliderCell) return ctx.rowSliderCell;
- const headerRow = gridHeaderRow(ctx.table);
+ const headerBlock = gridHeaderRows(ctx.table);
+ const headerRow = headerBlock[0];
if (!headerRow) throw new Error('No original header row found');
- const headerCell = buildRowHeaderCell();
+ // Insert into the TOP header row and span the whole header block; for a plain
+ // single-row header this is the header row with rowSpan 1 (unchanged).
+ const headerCell = buildRowHeaderCell(headerBlock.length);
headerRow.insertBefore(headerCell, headerRow.firstChild);
const cell = buildRowSliderCell(ctx.dataRowCount);
diff --git a/src/ui/__tests__/toggle-injector.banner-header.test.ts b/src/ui/__tests__/toggle-injector.banner-header.test.ts
new file mode 100644
index 0000000..7190000
--- /dev/null
+++ b/src/ui/__tests__/toggle-injector.banner-header.test.ts
@@ -0,0 +1,96 @@
+/**
+ * Merged-header interpolation: a table with a banner/grouping header row (speed
+ * vs length matrix) must still be recognised as suitable and offer the slider
+ * (interpolation) enrichment through the real activation path.
+ *
+ * Two author permutations are covered:
+ * 1. A full-width `` banner above the numeric column headers;
+ * the leaf header row carries the "Speed Knots" corner + length headers.
+ * 2. A ` Speed Knots ` corner beside a ``
+ * banner; the leaf header row is ALL length headers (no corner cell), with
+ * its cells shifted right by the rowspan.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { injectToggle, activateToggle, deactivateToggle } from '../toggle-injector';
+import { setPageConfig, setVisitorOverride } from '../../core/enabled-set-state';
+import { removeAllSliders } from '../../enrichments/slider';
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+ setVisitorOverride(undefined);
+ setPageConfig({ enrichments: undefined, showToggleUi: false, tables: [] });
+});
+
+afterEach(() => {
+ const t = document.querySelector('table');
+ if (t) removeAllSliders(t);
+});
+
+function bannerColspanTable(): HTMLTableElement {
+ const t = document.createElement('table');
+ t.id = 'perm1';
+ t.innerHTML = `
+
+ Length Overall (m)
+ Speed Knots 10 20 30
+
+
+ 10 1 2 3
+ 20 4 5 6
+ 30 7 8 9
+ `;
+ document.body.appendChild(t);
+ return t;
+}
+
+function rowspanCornerTable(): HTMLTableElement {
+ const t = document.createElement('table');
+ t.id = 'perm2';
+ t.innerHTML = `
+
+ Speed Knots Length Overall (m)
+ 10 20 30
+
+
+ 10 1 2 3
+ 20 4 5 6
+ 30 7 8 9
+ `;
+ document.body.appendChild(t);
+ return t;
+}
+
+function sliderLozenge(t: HTMLTableElement): HTMLElement | null {
+ return t.querySelector('[data-gs-lozenge-id="sliders"]');
+}
+
+describe('interpolation on merged-header tables', () => {
+ it('offers the slider lozenge for a full-width banner header (permutation 1)', () => {
+ const t = bannerColspanTable();
+ injectToggle(t);
+ activateToggle(t);
+ const slider = sliderLozenge(t);
+ expect(slider).not.toBeNull();
+ // Enabled (not the "no numeric axis" disabled state).
+ expect(slider!.getAttribute('aria-disabled')).not.toBe('true');
+ });
+
+ it('offers the slider lozenge for a rowspan corner + banner header (permutation 2)', () => {
+ const t = rowspanCornerTable();
+ injectToggle(t);
+ activateToggle(t);
+ const slider = sliderLozenge(t);
+ expect(slider).not.toBeNull();
+ expect(slider!.getAttribute('aria-disabled')).not.toBe('true');
+ });
+
+ it('activate → deactivate restores byte-identical markup (INV-4)', () => {
+ const t = rowspanCornerTable();
+ injectToggle(t);
+ const baseline = t.innerHTML;
+ activateToggle(t);
+ deactivateToggle(t);
+ expect(t.innerHTML).toBe(baseline);
+ });
+});
diff --git a/src/ui/header-utils.ts b/src/ui/header-utils.ts
index b566067..8121ab7 100644
--- a/src/ui/header-utils.ts
+++ b/src/ui/header-utils.ts
@@ -37,6 +37,8 @@ import {
columnCells,
cellValue,
logicalColIndexOf,
+ headerRows,
+ sourceHeaderColumns,
} from '../core/table-grid';
import { isTwinTable } from '../core/twin-grid';
import {
@@ -65,9 +67,19 @@ export function injectPlusIcons(table: HTMLTableElement, columnTypes: ColumnType
ensureRowVisibilityStyles();
ensureVirtualColumnStyles();
- const rows = gridRows(table);
- const headerRow = rows[0];
- if (!headerRow) return;
+ // Multi-row (banner) header: place per-column lozenges on the leaf header
+ // cells resolved by logical column (occupancy-aware over colspan + rowspan),
+ // and row lozenges on the body rows only — the banner/grouping rows carry no
+ // per-column affordances. A plain single-row header keeps the original path
+ // (which also covers virtual columns and author colspan headers unchanged).
+ const multiRowHeader = headerRows(table).length > 1;
+ const headerCells = multiRowHeader
+ ? sourceHeaderColumns(table)
+ : (() => {
+ const h = gridRows(table)[0];
+ return h ? gridCells(h) : [];
+ })();
+ if (!headerCells.length) return;
// Twin (grouped) tables (spec 016): the single-row-header addressing behind
// the per-column / per-row passes mis-places lozenges on the label columns and
@@ -76,12 +88,12 @@ export function injectPlusIcons(table: HTMLTableElement, columnTypes: ColumnType
// which carries the twin-aware slider toggle; the mis-placed H/# are not
// offered until those enrichments learn the group structure.
if (isTwinTable(table)) {
- const corner = gridCells(headerRow)[0];
+ const corner = headerCells[0];
if (corner) addLozengesToHeader(table, corner, 'table', 0);
return;
}
- gridCells(headerRow).forEach((cell, colIndex) => {
+ headerCells.forEach((cell, colIndex) => {
const isTopLeftCell = colIndex === 0;
const type = columnTypes[colIndex];
if (type === 'numeric' || type === 'categorical') {
@@ -89,8 +101,11 @@ export function injectPlusIcons(table: HTMLTableElement, columnTypes: ColumnType
}
});
- for (let i = 1; i < rows.length; i++) {
- const cells = gridCells(rows[i]);
+ const rowLozengeRows = multiRowHeader
+ ? bodyRows(table)
+ : gridRows(table).slice(1);
+ for (const row of rowLozengeRows) {
+ const cells = gridCells(row);
if (!cells.length) continue;
addLozengesToHeader(table, cells[0], 'row', 0);
}
diff --git a/src/ui/toggle-injector.ts b/src/ui/toggle-injector.ts
index ebd62b4..bfd71c8 100644
--- a/src/ui/toggle-injector.ts
+++ b/src/ui/toggle-injector.ts
@@ -15,6 +15,8 @@ import {
cellValue,
logicalColIndexOf,
logicalRowIndexOf,
+ headerRows,
+ sourceColumnMatrix,
} from '../core/table-grid';
import { onVisibleRowsChange, visibleBodyRows } from '../utils/visible-rows';
import { StatisticsPopup } from './statistics-popup';
@@ -443,9 +445,14 @@ export function activateToggle(table: HTMLTableElement): void {
// spec 014). Using raw textContent here would let an annotated numeric cell
// ("1200" + note) read as non-numeric and suppress a column's lozenges
// (spec 013: scaffold/UI is never the logical grid).
- const rows = Array.from(table.rows)
- .filter(row => !row.hasAttribute('data-gs-injected'))
- .map(row => Array.from(row.cells).map(cell => cellValue(cell)));
+ // A multi-row (banner) header is flattened by logical column so a merged
+ // banner / rowspan corner doesn't collapse the detected column count; a plain
+ // header keeps the raw per-cell read (which also covers virtual columns).
+ const rows = headerRows(table).length > 1
+ ? sourceColumnMatrix(table)
+ : Array.from(table.rows)
+ .filter(row => !row.hasAttribute('data-gs-injected'))
+ .map(row => Array.from(row.cells).map(cell => cellValue(cell)));
const { columnTypes } = analyzeTable(rows);
// Cache column types for the toggle-panel refresh path (spec 012 R-10).
setColumnTypes(table, columnTypes);