diff --git a/.changeset/table-checkbox-selection.md b/.changeset/table-checkbox-selection.md new file mode 100644 index 00000000..3c75bcaa --- /dev/null +++ b/.changeset/table-checkbox-selection.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': minor +--- + +Table: add `selectionBehavior="checkbox"`. A press on a row only performs its action, even while rows are selected; the selection changes only through the selection checkbox, `Space` and the select-all shortcut. diff --git a/packages/ui/src/lib/table/README.md b/packages/ui/src/lib/table/README.md index e3d286b2..f5d1e6c3 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="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. - `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. diff --git a/packages/ui/src/lib/table/root/README.md b/packages/ui/src/lib/table/root/README.md index a7cdd176..ffe92eac 100644 --- a/packages/ui/src/lib/table/root/README.md +++ b/packages/ui/src/lib/table/root/README.md @@ -11,34 +11,34 @@ Description: State container for interactive table behavior, including roving fo Public prop type: `TableRootProps` -| Prop | Type | Default | Description | -| ------------------------ | ------------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | Controls row selection behavior. | -| `selectionBehavior` | `'toggle' \| 'replace'` | `'toggle'` | `toggle` uses checkbox-style selection. `replace` makes click replace selection, vertical arrows move selection, and `Shift+ArrowUp/Down` extend it. | -| `disabledBehavior` | `'selection' \| 'all'` | `'all'` | Controls whether disabled rows only block selection or block both selection and row actions. | -| `disallowEmptySelection` | `boolean` | `false` | Prevents the internal selection state from becoming empty when selection is enabled. | -| `hiddenColumns` | `Iterable` | `undefined` | Controlled hidden column ids. Supports `bind:hiddenColumns`. | -| `defaultHiddenColumns` | `Iterable` | `undefined` | Initial hidden column ids for uncontrolled usage. | -| `selectedKeys` | `Iterable` | `undefined` | Controlled selected row ids. Supports `bind:selectedKeys`. | -| `defaultSelectedKeys` | `Iterable` | `undefined` | Initial selected row ids for uncontrolled usage. When `selectionMode` later becomes `none`, the internal selection is cleared. | -| `sortDescriptor` | `{ column: string; direction: 'ascending' \| 'descending' }` | `undefined` | Controlled sort state. Supports `bind:sortDescriptor`. Setting it back to `undefined` clears the sort. | -| `defaultSortDescriptor` | `{ column: string; direction: 'ascending' \| 'descending' }` | `undefined` | Initial sort state for uncontrolled usage. | -| `columnWidths` | `Map` | `undefined` | Controlled column width state. Supports px, `%`, and `fr` specs via `bind:columnWidths`. | -| `defaultColumnWidths` | `Iterable<[string, TableColumnWidth]>` | `undefined` | Initial uncontrolled column widths using px, `%`, or `fr` specs. | -| `disabledKeys` | `Iterable` | `undefined` | Row ids that should be non-selectable. | -| `onRowAction` | `(id: string \| number) => void` | `undefined` | Called when a row action is triggered for an actionable row. | -| `onSelectionChange` | `(keys: Set) => void` | `undefined` | Called when row selection changes. | -| `onSortChange` | `(descriptor) => void` | `undefined` | Called when sortable header state changes. | -| `onColumnWidthsChange` | `(widths: Map) => void` | `undefined` | Called when resize interactions update managed column widths. | -| `onHiddenColumnsChange` | `(columnIds: string[]) => void` | `undefined` | Called when hidden column state changes. | -| `onColumnResizeStart` | `(columnId: string) => void` | `undefined` | Called when a column resize drag starts. | -| `onColumnResizeEnd` | `(widths: Map) => void` | `undefined` | Called when a column resize drag ends. | -| `aria-label` | `string` | `undefined` | Accessible name when no external label is present. | -| `aria-labelledby` | `string` | `undefined` | Id reference for an external label. | -| `class` | `string` | `''` | Class names for the root table element. | -| `context` | `TableContext` | `undefined` | Bindable reference to the active internal table context instance. | -| `element` | `HTMLTableElement` | `undefined` | A bindable reference to the table element. | -| `children` | `Snippet` | `undefined` | Composed table parts. | +| Prop | Type | Default | Description | +| ------------------------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | Controls row selection behavior. | +| `selectionBehavior` | `'toggle' \| 'replace' \| 'checkbox'` | `'toggle'` | `toggle` uses checkbox-style selection. `replace` makes click replace selection, vertical arrows move selection, and `Shift+ArrowUp/Down` extend it. `checkbox` leaves the selection to the checkbox and `Space`: a press only performs the row action. | +| `disabledBehavior` | `'selection' \| 'all'` | `'all'` | Controls whether disabled rows only block selection or block both selection and row actions. | +| `disallowEmptySelection` | `boolean` | `false` | Prevents the internal selection state from becoming empty when selection is enabled. | +| `hiddenColumns` | `Iterable` | `undefined` | Controlled hidden column ids. Supports `bind:hiddenColumns`. | +| `defaultHiddenColumns` | `Iterable` | `undefined` | Initial hidden column ids for uncontrolled usage. | +| `selectedKeys` | `Iterable` | `undefined` | Controlled selected row ids. Supports `bind:selectedKeys`. | +| `defaultSelectedKeys` | `Iterable` | `undefined` | Initial selected row ids for uncontrolled usage. When `selectionMode` later becomes `none`, the internal selection is cleared. | +| `sortDescriptor` | `{ column: string; direction: 'ascending' \| 'descending' }` | `undefined` | Controlled sort state. Supports `bind:sortDescriptor`. Setting it back to `undefined` clears the sort. | +| `defaultSortDescriptor` | `{ column: string; direction: 'ascending' \| 'descending' }` | `undefined` | Initial sort state for uncontrolled usage. | +| `columnWidths` | `Map` | `undefined` | Controlled column width state. Supports px, `%`, and `fr` specs via `bind:columnWidths`. | +| `defaultColumnWidths` | `Iterable<[string, TableColumnWidth]>` | `undefined` | Initial uncontrolled column widths using px, `%`, or `fr` specs. | +| `disabledKeys` | `Iterable` | `undefined` | Row ids that should be non-selectable. | +| `onRowAction` | `(id: string \| number) => void` | `undefined` | Called when a row action is triggered for an actionable row. | +| `onSelectionChange` | `(keys: Set) => void` | `undefined` | Called when row selection changes. | +| `onSortChange` | `(descriptor) => void` | `undefined` | Called when sortable header state changes. | +| `onColumnWidthsChange` | `(widths: Map) => void` | `undefined` | Called when resize interactions update managed column widths. | +| `onHiddenColumnsChange` | `(columnIds: string[]) => void` | `undefined` | Called when hidden column state changes. | +| `onColumnResizeStart` | `(columnId: string) => void` | `undefined` | Called when a column resize drag starts. | +| `onColumnResizeEnd` | `(widths: Map) => void` | `undefined` | Called when a column resize drag ends. | +| `aria-label` | `string` | `undefined` | Accessible name when no external label is present. | +| `aria-labelledby` | `string` | `undefined` | Id reference for an external label. | +| `class` | `string` | `''` | Class names for the root table element. | +| `context` | `TableContext` | `undefined` | Bindable reference to the active internal table context instance. | +| `element` | `HTMLTableElement` | `undefined` | A bindable reference to the table element. | +| `children` | `Snippet` | `undefined` | Composed table parts. | ### Behavior notes diff --git a/packages/ui/src/lib/table/root/context.svelte.ts b/packages/ui/src/lib/table/root/context.svelte.ts index 60fc5172..7a00257d 100644 --- a/packages/ui/src/lib/table/root/context.svelte.ts +++ b/packages/ui/src/lib/table/root/context.svelte.ts @@ -27,7 +27,17 @@ export type TableSelectionMode = 'none' | 'single' | 'multiple'; * so it costs nothing, and it is where sorting and resizing live. */ export type TableKeyboardNavigation = 'grid' | 'row' | 'none'; -export type TableSelectionBehavior = 'toggle' | 'replace'; +/** + * How a press on a row relates to the selection. + * + * - `'toggle'`: a press toggles the row, checkbox-style. With `onRowAction`, a press performs the + * action while nothing is selected and toggles once something is. + * - `'replace'`: a press replaces the selection with the row; the arrows move it. + * - `'checkbox'`: a press never touches the selection. The row performs its action (if there is + * one), and only the selection checkbox, `Space` or the select-all shortcut select. Use it when + * the rows open something and selecting is a deliberate, secondary act. + */ +export type TableSelectionBehavior = 'toggle' | 'replace' | 'checkbox'; export type TableDisabledBehavior = 'selection' | 'all'; export type TableSortDirection = 'ascending' | 'descending'; export type TableSectionKind = 'header' | 'body' | 'footer'; @@ -2455,8 +2465,8 @@ export function createTableContext(options: CreateTableContextOptions = {}): Tab if (selectionMode === 'single') { const wasSelected = selectedKeys.has(id); applySelectionChange( - selectionBehavior === 'toggle' && wasSelected ? new SvelteSet() : new SvelteSet([id]), - selectionBehavior === 'toggle' && wasSelected ? null : id + selectionBehavior !== 'replace' && wasSelected ? new SvelteSet() : new SvelteSet([id]), + selectionBehavior !== 'replace' && wasSelected ? null : id ); return; } @@ -2545,6 +2555,7 @@ export function createTableContext(options: CreateTableContextOptions = {}): Tab performRowAction(id); return; } + if (selectionBehavior === 'checkbox') return; pressRowSelection(id, interaction); return; } @@ -2562,6 +2573,12 @@ export function createTableContext(options: CreateTableContextOptions = {}): Tab return; } + // Only the checkbox selects: the row does what it does, or nothing. + if (selectionBehavior === 'checkbox') { + if (source === 'pointer' && onRowAction) performRowAction(id); + return; + } + if (source === 'pointer-double') { if (onRowAction && selectionMode !== 'none' && selectionBehavior === 'replace') { performRowAction(id); diff --git a/packages/ui/src/lib/table/root/table-root.test.ts b/packages/ui/src/lib/table/root/table-root.test.ts index 99fcc0c1..24fc0c32 100644 --- a/packages/ui/src/lib/table/root/table-root.test.ts +++ b/packages/ui/src/lib/table/root/table-root.test.ts @@ -460,6 +460,51 @@ describe('Table.Root', () => { expect(secondBodyCell.getAttribute('data-actionable')).toBe('true'); }); + it('keeps running onRowAction on click in checkbox mode while a selection exists', async () => { + render(TableTest, { + selectionMode: 'multiple', + selectionBehavior: 'checkbox', + initialSelectedKeys: ['danilo'], + onRowAction: vi.fn() + }); + const secondBodyCell = getBodyFirstColumnCells(document.body)[1]; + + await userEvent.click(secondBodyCell); + + await expect + .poll(() => document.querySelector('[data-testid="row-action-log"]')?.textContent) + .toBe('["zahra"]'); + expect(document.querySelector('[data-testid="selected-keys"]')?.textContent).toBe('["danilo"]'); + }); + + it('leaves the selection alone on click in checkbox mode without a row action', async () => { + render(TableTest, { selectionMode: 'multiple', selectionBehavior: 'checkbox' }); + const firstBodyCell = document.querySelector('tbody [role="rowheader"]')!; + + await userEvent.click(firstBodyCell); + firstBodyCell.focus(); + await userEvent.keyboard('{Enter}'); + + expect(document.querySelector('[data-testid="selected-keys"]')?.textContent).toBe('[]'); + }); + + it('still selects with Space in checkbox mode', async () => { + render(TableTest, { + selectionMode: 'multiple', + selectionBehavior: 'checkbox', + onRowAction: vi.fn() + }); + const firstBodyCell = document.querySelector('tbody [role="rowheader"]')!; + + firstBodyCell.focus(); + await userEvent.keyboard(' '); + + await expect + .poll(() => document.querySelector('[data-testid="selected-keys"]')?.textContent) + .toBe('["danilo"]'); + expect(document.querySelector('[data-testid="row-action-log"]')?.textContent).toBe('[]'); + }); + it('uses Enter for action and Space for selection when onRowAction is provided', async () => { render(TableTest, { selectionMode: 'multiple',