From 2c2acfb2bb61fcadeffa64b9e5759586626b4877 Mon Sep 17 00:00:00 2001 From: Agustin Delgado Date: Fri, 16 Oct 2026 19:16:07 -0300 Subject: [PATCH 1/2] feat(table): let sectionRowHeight size each section row on its own A function of the item, for a body whose section rows are not all alike: a group's header beside the header of an expanded row's children. --- .../table-section-row-height-function.md | 5 ++ packages/ui/src/lib/table/README.md | 1 + .../ui/src/lib/table/body/table-body.svelte | 5 +- .../section-row/table-section-row-test.svelte | 13 +++-- .../section-row/table-section-row.test.ts | 47 +++++++++++++++++++ packages/ui/src/lib/table/types.ts | 10 ++-- 6 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 .changeset/table-section-row-height-function.md diff --git a/.changeset/table-section-row-height-function.md b/.changeset/table-section-row-height-function.md new file mode 100644 index 00000000..084d0a28 --- /dev/null +++ b/.changeset/table-section-row-height-function.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': minor +--- + +Table: `sectionRowHeight` of the body's virtualizer also takes a function of the item, so section rows of different kinds (a group's header, the header of an expanded row's children) can each keep their own height. diff --git a/packages/ui/src/lib/table/README.md b/packages/ui/src/lib/table/README.md index f5d1e6c3..39258c7b 100644 --- a/packages/ui/src/lib/table/README.md +++ b/packages/ui/src/lib/table/README.md @@ -182,6 +182,7 @@ Put the groups and their rows in one `items` array. In the `children` snippet of - A click, `Enter` or `Space` calls `onAction` of the section row. The table does not call `onRowAction` for a section row. - A mounted section row tells the table that it is a section row. With a virtualizer, also set `isSectionItem` on `Table.Body`, because the table does not know the section rows that are not mounted. - With a virtualizer, set `sectionRowHeight` when a section row has a different height from `rowHeight`. This also needs `isSectionItem`. Give the rows the same heights in CSS. +- When the section rows are not all the same height, pass `sectionRowHeight` as a function of the item. The table calls it only for the items that `isSectionItem` accepts. - The cell is not sticky. In a table with pinned columns, add `sticky left-0` (or a similar class) to the content of the section row to keep it in view. ## Composition contract diff --git a/packages/ui/src/lib/table/body/table-body.svelte b/packages/ui/src/lib/table/body/table-body.svelte index 572a43f3..f19b479a 100644 --- a/packages/ui/src/lib/table/body/table-body.svelte +++ b/packages/ui/src/lib/table/body/table-body.svelte @@ -98,10 +98,13 @@ if (!virtualizer || !virtualizerEnabled || !isSectionItem) return undefined; const { rowHeight, sectionRowHeight } = virtualizer; if (sectionRowHeight === undefined || sectionRowHeight === rowHeight) return undefined; + const sectionHeight = + typeof sectionRowHeight === 'function' ? sectionRowHeight : () => sectionRowHeight; const offsets = new Float64Array(itemList.length + 1); for (let index = 0; index < itemList.length; index += 1) { - const height = isSectionItem(itemList[index]) ? sectionRowHeight : rowHeight; + const item = itemList[index]; + const height = isSectionItem(item) ? sectionHeight(item) : rowHeight; offsets[index + 1] = offsets[index] + height; } return offsets; diff --git a/packages/ui/src/lib/table/section-row/table-section-row-test.svelte b/packages/ui/src/lib/table/section-row/table-section-row-test.svelte index 014d9a32..633ca84f 100644 --- a/packages/ui/src/lib/table/section-row/table-section-row-test.svelte +++ b/packages/ui/src/lib/table/section-row/table-section-row-test.svelte @@ -25,11 +25,18 @@ keyboardNavigation?: TableKeyboardNavigation; hiddenColumns?: string[]; declareSections?: boolean; - virtualizer?: TableBodyVirtualizer; + virtualizer?: TableBodyVirtualizer; onRowAction?: (id: TableSelectionKey) => void; onSectionAction?: (id: string) => void; }; + /** The section row's own height, as the virtualizer is told it, so the two agree. */ + function sectionStyle(item: GroupItem) { + const height = virtualizer?.sectionRowHeight; + if (height === undefined) return undefined; + return `height:${typeof height === 'function' ? height(item) : height}px;`; + } + let { items = defaultItems, selectionMode = 'multiple', @@ -92,9 +99,7 @@ id={item.id} aria-expanded={!collapsed.has(item.id)} data-testid={`section-${item.id}`} - style={virtualizer?.sectionRowHeight - ? `height:${virtualizer.sectionRowHeight}px;` - : undefined} + style={sectionStyle(item)} onAction={() => toggleGroup(item.id)} > {item.label} diff --git a/packages/ui/src/lib/table/section-row/table-section-row.test.ts b/packages/ui/src/lib/table/section-row/table-section-row.test.ts index b9a046e0..ff1cd748 100644 --- a/packages/ui/src/lib/table/section-row/table-section-row.test.ts +++ b/packages/ui/src/lib/table/section-row/table-section-row.test.ts @@ -222,4 +222,51 @@ describe('Table.SectionRow', () => { scroller.dispatchEvent(new Event('scroll')); await expect.poll(() => Boolean(getSectionRow('group-10'))).toBe(true); }); + + it('sizes each section row on its own when the height is a function', async () => { + const items = createManyItems(20, 5); + // Even groups are 24px high, odd ones 40px. + const heightOf = (id: string) => (Number(id.split('-')[1]) % 2 === 0 ? 24 : 40); + render(SectionRowTest, { + items, + declareSections: true, + virtualizer: { + rowHeight: 32, + sectionRowHeight: (item) => heightOf(item.id), + overscan: 0 + } + }); + + await expect + .poll( + () => + document.querySelector('[data-virtual-spacer="bottom"] div')?.style.height + ) + .toBeTruthy(); + + // 10 x 24 + 10 x 40 + 100 x 32 = 3840px in all. + const mountedHeight = Array.from( + document.querySelectorAll('tbody tr[data-testid]') + ).reduce( + (total, row) => + total + + (row.hasAttribute('data-section-row') + ? heightOf(row.dataset.testid!.replace('section-', '')) + : 32), + 0 + ); + const top = parseFloat( + document.querySelector('[data-virtual-spacer="top"] div')?.style.height ?? '0' + ); + const bottom = parseFloat( + document.querySelector('[data-virtual-spacer="bottom"] div')!.style.height + ); + expect(top + mountedHeight + bottom).toBe(3840); + + const scroller = document.querySelector('table')!.parentElement!; + // Group 10 starts at 5 x 24 + 5 x 40 + 10 x 160 = 1920px. + scroller.scrollTop = 1920; + scroller.dispatchEvent(new Event('scroll')); + await expect.poll(() => Boolean(getSectionRow('group-10'))).toBe(true); + }); }); diff --git a/packages/ui/src/lib/table/types.ts b/packages/ui/src/lib/table/types.ts index 542f1b43..d9048402 100644 --- a/packages/ui/src/lib/table/types.ts +++ b/packages/ui/src/lib/table/types.ts @@ -80,13 +80,17 @@ export type TableHeaderProps = Omit, 'ch class?: string; }; -export type TableBodyVirtualizer = { +export type TableBodyVirtualizer = { rowHeight: number; /** * Height, in px, of a section row, when it differs from `rowHeight`. It needs `isSectionItem` on * `Table.Body` to know which items are section rows. Without it, each item is `rowHeight` high. + * + * A function sizes each section row on its own, for a body whose section rows are not all + * alike — a group's header beside the header of an open row's children, say. It is only + * called for the items `isSectionItem` accepts. */ - sectionRowHeight?: number; + sectionRowHeight?: number | ((item: T) => number); /** * Extra rows rendered above and below the viewport, defaulting to 18. It * buffers against row render cost: while the main thread builds rows, the @@ -110,7 +114,7 @@ export type TableBodyManualProps = TableBodyBaseProps & { export type TableBodyItemsProps = TableBodyBaseProps & { items: readonly T[]; - virtualizer?: TableBodyVirtualizer; + virtualizer?: TableBodyVirtualizer; /** * Tells which items render a `Table.SectionRow`. A section row that is mounted declares itself, * so this is optional without a virtualizer. With one, pass it: it keeps the section rows that From 17b5978267a81ec44a70dec1f57c9a7e9e6b566f Mon Sep 17 00:00:00 2001 From: Agustin Delgado Date: Fri, 16 Oct 2026 19:19:55 -0300 Subject: [PATCH 2/2] docs(table): write the checkbox selection sentence in ASD-STE100 --- packages/ui/src/lib/table/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/lib/table/README.md b/packages/ui/src/lib/table/README.md index 39258c7b..26f84a25 100644 --- a/packages/ui/src/lib/table/README.md +++ b/packages/ui/src/lib/table/README.md @@ -95,7 +95,7 @@ and `TableCheckboxIndicatorProps`. - Use `Table.Root` as the stateful container for focus, selection, and sorting state. - Use `keyboardNavigation` to choose how far the roving tab stop reaches into the body: `'grid'` (default) makes every body cell a focus target, `'row'` makes the row the only one, and `'none'` leaves the body inert. The cell navigation has a cost: one focus registration and one focus state for each cell. A virtualized table does this again for each row that it makes. Use `'row'` when the body is a list, and not a surface where the user works. Use `'none'` only when nothing in the body does an action. The header keeps its own navigation in every mode. -- Use `selectionBehavior="toggle"` to let the user remove the selection of a selected row. Use `selectionBehavior="replace"` to keep a selected row selected when the user presses it again. Use `selectionBehavior="checkbox"` when a press on the row should only perform its action: then only the selection checkbox, `Space` or the select-all shortcut change the selection. +- Use `selectionBehavior="toggle"` to let the user remove the selection of a selected row. Use `selectionBehavior="replace"` to keep a selected row selected when the user presses it again. Use `selectionBehavior="checkbox"` when a press on the row must only do its action. Then only the selection checkbox, `Space` or the select-all shortcut change the selection. - `Table.Column` is a logical-only wrapper for column metadata; it does not render DOM by itself and should wrap a single `Table.ColumnHeaderCell`. - Wrap each header cell in `Table.Column` so the table can register stable column metadata. - Add `Table.ColumnResizer` inside `Table.ColumnHeaderCell` to make the owning `Table.Column` resizable.