Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/table-section-row-height-function.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/ui/src/lib/table/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/lib/table/body/table-body.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@
keyboardNavigation?: TableKeyboardNavigation;
hiddenColumns?: string[];
declareSections?: boolean;
virtualizer?: TableBodyVirtualizer;
virtualizer?: TableBodyVirtualizer<ListItem>;
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',
Expand Down Expand Up @@ -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)}
>
<span>{item.label}</span>
Expand Down
47 changes: 47 additions & 0 deletions packages/ui/src/lib/table/section-row/table-section-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[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<HTMLElement>('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<HTMLElement>('[data-virtual-spacer="top"] div')?.style.height ?? '0'
);
const bottom = parseFloat(
document.querySelector<HTMLElement>('[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);
});
});
10 changes: 7 additions & 3 deletions packages/ui/src/lib/table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,17 @@ export type TableHeaderProps = Omit<HTMLAttributes<HTMLTableSectionElement>, 'ch
class?: string;
};

export type TableBodyVirtualizer = {
export type TableBodyVirtualizer<T extends TableRowItem = TableRowItem> = {
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
Expand All @@ -110,7 +114,7 @@ export type TableBodyManualProps = TableBodyBaseProps & {

export type TableBodyItemsProps<T extends TableRowItem = TableRowItem> = TableBodyBaseProps & {
items: readonly T[];
virtualizer?: TableBodyVirtualizer;
virtualizer?: TableBodyVirtualizer<T>;
/**
* 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
Expand Down
Loading