From 11555ea93847f605d37b516f6d017e2da852e4d6 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 24 Sep 2026 15:18:33 +0200 Subject: [PATCH 1/3] Offer a column's known values as its filter, and give the filter a real icon A column whose values come from a known set - a status, a kind, an enum rendered as text - could only be filtered by typing. The menu picked its input from dataType alone, and 'boolean' was the only case that listed values, so an enum column declared numeric asked the user to type the underlying number while the cell showed the word. Column.filterOptions supplies those values, rendered as a list and narrowed to equals/not equals, because 'starts with' means nothing over a closed set. The trigger also rendered U+2315 TELEPHONE RECORDER in a span, sized by the ambient font - which is why it read as a tiny unrecognizable mark rather than a filter. It is now a funnel drawn as an SVG and sized against the header row. --- Source/Common/CratisComponentsProvider.tsx | 2 + Source/DataPage/DataPage.tsx | 37 ++++- Source/DataTables/Column.tsx | 15 +- Source/DataTables/ColumnFilterMenu.css | 8 ++ .../DataTables/ColumnFilterMenu.stories.tsx | 13 ++ Source/DataTables/ColumnFilterMenu.tsx | 55 +++++++- Source/DataTables/DataTableCore.stories.tsx | 33 +++++ Source/DataTables/DataTableCore.tsx | 129 ++++++++++++++++-- .../DataTableForObservableQuery.tsx | 19 ++- Source/DataTables/DataTableForQuery.tsx | 19 ++- ...n_filtering_over_a_known_set_of_values.tsx | 67 +++++++++ .../when_selecting_multiple_rows.tsx | 120 ++++++++++++++++ .../configuration/CratisComponentsDefaults.ts | 1 + 13 files changed, 497 insertions(+), 21 deletions(-) create mode 100644 Source/DataTables/for_ColumnFilterMenu/when_filtering_over_a_known_set_of_values.tsx create mode 100644 Source/DataTables/for_DataTableCore/when_selecting_multiple_rows.tsx diff --git a/Source/Common/CratisComponentsProvider.tsx b/Source/Common/CratisComponentsProvider.tsx index e9176e64..29ab3099 100644 --- a/Source/Common/CratisComponentsProvider.tsx +++ b/Source/Common/CratisComponentsProvider.tsx @@ -97,6 +97,8 @@ export interface CratisNotificationsMessages { export interface CratisDataTableMessages { /** Accessible name for a single-selection row control. */ selectRow?: string; + /** Accessible name for the select-all control in a multiple-selection header. */ + selectAllRows?: string; /** Placeholder for the loaded-page search input. */ search?: string; /** Accessible name for the loaded-page search input. */ diff --git a/Source/DataPage/DataPage.tsx b/Source/DataPage/DataPage.tsx index d96aad88..8d750df4 100644 --- a/Source/DataPage/DataPage.tsx +++ b/Source/DataPage/DataPage.tsx @@ -106,7 +106,10 @@ const pageStyle: CSSProperties = { minHeight: '20rem' }; */ export const MenuItems = ({ children }: MenuItemsProps) => { const context = useDataPageContext(); - const isDisabled = !context.selectedItem; + const isDisabled = + context.selectionMode === 'multiple' + ? context.selectedItems.length === 0 + : !context.selectedItem; const items = useMemo(() => { const menuItems: ActionMenuItem[] = []; @@ -223,6 +226,8 @@ interface IDataPageContext extends DataPageProps< > { selectedItem: object | null | undefined; onSelectionChanged: (event: DataTableSelectionChangeEvent) => void; + selectedItems: object[]; + onSelectedItemsChange: (items: object[]) => void; } const DataPageContext = React.createContext(null); @@ -296,6 +301,24 @@ export interface DataPageProps< */ onSelectionChange?(event: DataTableSelectionChangeEvent): void; + /** + * The row selection mode. Defaults to `'single'`. Use `'multiple'` for a page whose actions + * operate on a set of rows - it adds per-row checkboxes and a select-all header checkbox, and + * reports through {@link onSelectedItemsChange}. + */ + selectionMode?: 'single' | 'multiple'; + + /** + * The currently selected rows, for `selectionMode='multiple'`. Leave undefined to let the page + * own the selection. + */ + selectedItems?: TDataType[]; + + /** + * Callback for when a multiple selection changes, with the full selected set. + */ + onSelectedItemsChange?(items: TDataType[]): void; + /** * Fields to use for global filtering */ @@ -511,6 +534,16 @@ const DataPage = < props.onSelectionChange?.(event); }; + const [internalSelectedItems, setInternalSelectedItems] = React.useState( + props.selectedItems ?? [], + ); + const selectedItems = props.selectedItems ?? internalSelectedItems; + + const selectedItemsChanged = (items: TDataType[]) => { + if (props.selectedItems === undefined) setInternalSelectedItems(items); + props.onSelectedItemsChange?.(items); + }; + // SAFETY: React context cannot retain this component's generic parameters. The // provider and every consumer are nested in the same DataPage invocation, so the // erased object-level context never crosses between differently typed pages. @@ -518,6 +551,8 @@ const DataPage = < ...props, selectedItem, onSelectionChanged: selectionChanged, + selectedItems, + onSelectedItemsChange: selectedItemsChanged, } as unknown as IDataPageContext; return ( diff --git a/Source/DataTables/Column.tsx b/Source/DataTables/Column.tsx index 6a8d0f28..4e2da6bd 100644 --- a/Source/DataTables/Column.tsx +++ b/Source/DataTables/Column.tsx @@ -7,6 +7,7 @@ import type { ColumnFilterElement, ColumnFilterMenuLabels, ColumnFilterMenuParts, + ColumnFilterOption, } from './ColumnFilterMenu'; /** @@ -35,12 +36,22 @@ export interface ColumnProps { showFilterMatchModes?: boolean; /** Custom value editor rendered instead of the built-in column filter editor. */ filterElement?: ColumnFilterElement; + /** + * The values this column can be filtered by. Supply this for a column whose values come from a + * known set - a status, a kind, an enum rendered as text - so the filter offers those values + * instead of asking the user to type one. Takes precedence over {@link filterElement}, and + * narrows the match modes to equals/not equals. + */ + filterOptions?: ColumnFilterOption[]; /** Overrides the column filter menu's default English labels. */ filterLabels?: Partial; /** Stable part attributes for this column's filter popup. */ filterPt?: ColumnFilterMenuParts; - /** Renders a single-row selection radio instead of a data column. */ - selectionMode?: 'single'; + /** + * Renders a selection control instead of a data column - a radio for `'single'`, or a checkbox + * plus a select-all checkbox in the header for `'multiple'`. + */ + selectionMode?: 'single' | 'multiple'; /** Inline style for every body cell in the column. */ style?: React.CSSProperties; /** Class name for every body cell in the column. */ diff --git a/Source/DataTables/ColumnFilterMenu.css b/Source/DataTables/ColumnFilterMenu.css index 88d314a6..739c3d3d 100644 --- a/Source/DataTables/ColumnFilterMenu.css +++ b/Source/DataTables/ColumnFilterMenu.css @@ -13,6 +13,14 @@ border-radius: 0.25rem; } +/* Sized against the header row rather than the ambient font, so the affordance stays legible and + clickable whatever type scale the surrounding table is rendered at. */ +.cratis-filter-trigger__icon { + width: 0.875rem; + height: 0.875rem; + display: block; +} + .cratis-filter-trigger:hover { background: var(--cratis-surface-hover); color: var(--cratis-text-color); diff --git a/Source/DataTables/ColumnFilterMenu.stories.tsx b/Source/DataTables/ColumnFilterMenu.stories.tsx index 62516da0..80562697 100644 --- a/Source/DataTables/ColumnFilterMenu.stories.tsx +++ b/Source/DataTables/ColumnFilterMenu.stories.tsx @@ -41,6 +41,19 @@ export const Boolean: Story = { play: openMenu, }; +/** A column whose values come from a known set offers those values instead of a free-form input. */ +export const KnownValues: Story = { + args: { + field: 'runningState', + filterOptions: [ + { label: 'Active', value: 1 }, + { label: 'Disconnected', value: 4 }, + { label: 'Quarantined', value: 5 }, + ], + }, + play: openMenu, +}; + export const LocalizedAndCustomized: Story = { args: { labels: { diff --git a/Source/DataTables/ColumnFilterMenu.tsx b/Source/DataTables/ColumnFilterMenu.tsx index 48615f11..4722713b 100644 --- a/Source/DataTables/ColumnFilterMenu.tsx +++ b/Source/DataTables/ColumnFilterMenu.tsx @@ -31,6 +31,14 @@ import { /** Value editor used by a built-in column filter. */ export type ColumnFilterDataType = 'text' | 'numeric' | 'date' | 'boolean'; +/** One selectable value offered by a column filter that filters over a known set. */ +export interface ColumnFilterOption { + /** The text shown for this value in the filter menu. */ + label: string; + /** The value written into the filter constraint when this option is picked. */ + value: unknown; +} + /** Localizable labels owned by the column filter popup. */ export interface ColumnFilterMenuLabels { /** Builds the filter-trigger accessible name from the effective field. */ @@ -126,6 +134,8 @@ export interface ColumnFilterMenuProps { showMatchModes?: boolean; /** Custom value editor replacing the built-in control. */ filterElement?: ColumnFilterElement; + /** The values this column can be filtered by, offered as a list instead of a free-form input. */ + filterOptions?: ColumnFilterOption[]; /** Partial localization overrides. */ labels?: Partial; /** Stable part attributes. */ @@ -143,6 +153,13 @@ interface MatchModeOption { value: FilterMatchMode; } +// A column that filters over a known set of values has only two questions worth asking of it - is it +// this one, or is it not - so the free-text and numeric comparisons are not offered for it. +const valueMatchModeOptions: MatchModeOption[] = [ + { label: 'Equals', value: DataTableFilterMatchMode.Equals }, + { label: 'Not equals', value: DataTableFilterMatchMode.NotEquals }, +]; + const optionsFor = (dataType: ColumnFilterDataType): MatchModeOption[] => { switch (dataType) { case 'numeric': @@ -191,6 +208,7 @@ export const ColumnFilterMenu = ({ placeholder, showMatchModes = true, filterElement, + filterOptions, labels, pt, constraint, @@ -272,10 +290,12 @@ export const ColumnFilterMenu = ({ true: labels?.true ?? columnFilterMessages?.true ?? defaultColumnFilterMenuLabels.true, false: labels?.false ?? columnFilterMessages?.false ?? defaultColumnFilterMenuLabels.false, }; - const modeOptions = optionsFor(dataType).map((option) => ({ - ...option, - label: resolvedLabels.matchModeLabel(option.value, option.label), - })); + const modeOptions = (filterOptions ? valueMatchModeOptions : optionsFor(dataType)).map( + (option) => ({ + ...option, + label: resolvedLabels.matchModeLabel(option.value, option.label), + }), + ); if (!modeOptions.some((option) => option.value === draftMode)) { modeOptions.push({ label: resolvedLabels.matchModeLabel(draftMode, String(draftMode)), @@ -298,7 +318,22 @@ export const ColumnFilterMenu = ({ event?.preventDefault(); }; - const valueInput = filterElement + const valueInput = filterOptions + ? ( + setDraftValue(() => value)} + aria-label={resolvedLabels.valueAriaLabel(field)} + className={pt?.input?.className} + style={pt?.input?.style} + /> + ) + : filterElement ? filterElement({ field, value: draftValue, @@ -390,7 +425,15 @@ export const ColumnFilterMenu = ({ data-open={isOpen || undefined} data-cratis-part='filter-trigger' > - + , }; +const MultipleSelectionTable = () => { + const [selected, setSelected] = useState([people[1]]); + return ( + + data={people} + dataKey='id' + emptyMessage='No people' + selectionMode='multiple' + selectedItems={selected} + onSelectedItemsChange={setSelected} + globalFilterFields={['name', 'role']} + > + selectionMode='multiple' /> + field='name' header='Name' sortable /> + + field='role' + header='Role' + filter + filterOptions={[ + { label: 'Administrator', value: 'Administrator' }, + { label: 'Developer', value: 'Developer' }, + { label: 'Viewer', value: 'Viewer' }, + ]} + /> + + ); +}; + +/** Bulk selection: a checkbox per row, and a select-all that covers the rows the filter leaves visible. */ +export const MultipleSelection: Story = { + render: () => , +}; + export const Empty: Story = { render: () => ( data={[]} emptyMessage='No people'> diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index 2455c906..bd0f2dee 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -2,8 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import React, { + useEffect, useId, useMemo, + useRef, useState, type CSSProperties, type HTMLAttributes, @@ -75,14 +77,24 @@ export interface DataTableCoreProps { dataKey?: string; /** Content shown when the loaded page has no matching rows. */ emptyMessage: ReactNode; - /** Enables single-row selection. */ - selectionMode?: 'single'; + /** + * Enables row selection. `'single'` selects one row at a time through row activation; + * `'multiple'` adds per-row checkboxes and a select-all header checkbox, and reports through + * {@link onSelectedItemsChange} rather than {@link onSelectionChange}. + */ + selectionMode?: 'single' | 'multiple'; /** Accessible name for row selection controls. Falls back to the provider's `dataTable.selectRow` message, then `'Select row'`. */ selectionAriaLabel?: string; - /** Controlled selected row. */ + /** Accessible name for the select-all control. Falls back to the provider's `dataTable.selectAllRows` message, then `'Select all rows'`. */ + selectAllAriaLabel?: string; + /** Controlled selected row. Applies to `selectionMode='single'`. */ selection?: TData | null; - /** Invoked when row selection changes. */ + /** Invoked when the single-row selection changes. */ onSelectionChange?: (event: DataTableSelectionChangeEvent) => void; + /** Controlled selected rows. Applies to `selectionMode='multiple'`. */ + selectedItems?: TData[]; + /** Invoked with the full set of selected rows when a multiple selection changes. */ + onSelectedItemsChange?: (items: TData[]) => void; /** Invoked when a row is clicked or keyboard activated. */ onRowClick?: (event: DataTableRowClickEvent) => void; /** Builds an extra class name for one row. */ @@ -258,8 +270,11 @@ export const DataTableCore = ({ emptyMessage, selectionMode, selectionAriaLabel, + selectAllAriaLabel, selection, onSelectionChange, + selectedItems, + onSelectedItemsChange, onRowClick, rowClassName, globalFilterFields, @@ -277,6 +292,8 @@ export const DataTableCore = ({ const dataTableMessages = messages?.dataTable; const resolvedSelectionAriaLabel = selectionAriaLabel ?? dataTableMessages?.selectRow ?? 'Select row'; + const resolvedSelectAllAriaLabel = + selectAllAriaLabel ?? dataTableMessages?.selectAllRows ?? 'Select all rows'; const resolvedGlobalSearchPlaceholder = globalSearchPlaceholder ?? dataTableMessages?.search ?? 'Search…'; const resolvedGlobalSearchAriaLabel = @@ -341,6 +358,9 @@ export const DataTableCore = ({ if (selectionMode === 'single') { onSelectionChange?.({ value: row, originalEvent }); } + if (selectionMode === 'multiple') { + toggleRowSelection(row); + } }; const dataKeyIdentity = (row: TData) => @@ -376,6 +396,61 @@ export const DataTableCore = ({ return loadedIndex === firstLoadedIndexByDataKey.get(identity); }; + // Membership is by dataKey when there is one, and by object identity otherwise. A table whose + // rows are replaced wholesale on every refresh - which is every observable query - keeps its + // selection only in the first case, which is why dataKey matters here as much as it does for + // single selection. + const selectedItemsList = useMemo(() => selectedItems ?? [], [selectedItems]); + const isRowSelected = (row: TData) => + dataKey + ? selectedItemsList.some( + (selected) => dataKeyIdentity(selected) === dataKeyIdentity(row), + ) + : selectedItemsList.includes(row); + + const toggleRowSelection = (row: TData) => { + const next = isRowSelected(row) + ? selectedItemsList.filter((selected) => + dataKey + ? dataKeyIdentity(selected) !== dataKeyIdentity(row) + : selected !== row, + ) + : [...selectedItemsList, row]; + onSelectedItemsChange?.(next); + }; + + // Select-all means the rows the user can currently see. A filtered table that silently selected + // rows hidden behind the filter would act on more than it showed, which is the whole hazard of a + // bulk action. + const visibleRows = filteredRows.map(({ row }) => row); + const isVisibleRow = (row: TData) => + dataKey + ? visibleRows.some((visible) => dataKeyIdentity(visible) === dataKeyIdentity(row)) + : visibleRows.includes(row); + const selectedVisibleCount = visibleRows.filter((row) => isRowSelected(row)).length; + const allFilteredRowsSelected = + visibleRows.length > 0 && selectedVisibleCount === visibleRows.length; + const someFilteredRowsSelected = + selectedVisibleCount > 0 && !allFilteredRowsSelected; + const selectAllRef = useRef(null); + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someFilteredRowsSelected; + } + }, [someFilteredRowsSelected]); + + const toggleSelectAll = () => { + if (allFilteredRowsSelected) { + onSelectedItemsChange?.( + selectedItemsList.filter((selected) => !isVisibleRow(selected)), + ); + return; + } + const additions = visibleRows.filter((row) => !isRowSelected(row)); + onSelectedItemsChange?.([...selectedItemsList, ...additions]); + }; + + return (
({ data-cratis-part='header-content' data-selected={Boolean(ariaSort) || undefined} > - {column.props.selectionMode && ( - - {resolvedSelectionAriaLabel} - + {column.props.selectionMode === + 'multiple' ? ( + + ) : ( + column.props.selectionMode && ( + + {resolvedSelectionAriaLabel} + + ) )} {column.props.sortable && column.props.field ? ( @@ -538,6 +627,9 @@ export const DataTableCore = ({ filterElement={ column.props.filterElement } + filterOptions={ + column.props.filterOptions + } labels={column.props.filterLabels} pt={column.props.filterPt} constraint={firstConstraint( @@ -596,7 +688,9 @@ export const DataTableCore = ({ selectionMode === 'single' && isSelectedRow(row, loadedIndex); const isInteractive = - Boolean(onRowClick) || selectionMode === 'single'; + Boolean(onRowClick) || + selectionMode === 'single' || + selectionMode === 'multiple'; return ( ({ data-cratis-part='cell' data-selected={isSelected || undefined} > - {column.props.selectionMode ? ( + {column.props.selectionMode === + 'multiple' ? ( + + event.stopPropagation() + } + onChange={() => + toggleRowSelection(row) + } + /> + ) : column.props.selectionMode ? ( ): void; + /** + * The row selection mode. Defaults to `'single'`. + */ + selectionMode?: 'single' | 'multiple'; + + /** + * The currently selected rows, for `selectionMode='multiple'`. + */ + selectedItems?: TDataType[]; + + /** + * Callback for when a multiple selection changes, with the full selected set. + */ + onSelectedItemsChange?(items: TDataType[]): void; + /** * Fields to use for global filtering */ @@ -250,9 +265,11 @@ export const DataTableForObservableQuery = < data={rows} dataKey={props.dataKey} emptyMessage={emptyMessage} - selectionMode='single' + selectionMode={props.selectionMode ?? 'single'} selection={props.selection} onSelectionChange={props.onSelectionChange} + selectedItems={props.selectedItems} + onSelectedItemsChange={props.onSelectedItemsChange} globalFilterFields={props.globalFilterFields} globalSearchPlaceholder={props.globalSearchPlaceholder} globalSearchAriaLabel={props.globalSearchAriaLabel} diff --git a/Source/DataTables/DataTableForQuery.tsx b/Source/DataTables/DataTableForQuery.tsx index d0c75328..f0765d0f 100644 --- a/Source/DataTables/DataTableForQuery.tsx +++ b/Source/DataTables/DataTableForQuery.tsx @@ -62,6 +62,21 @@ export interface DataTableForQueryProps< */ onSelectionChange?(event: DataTableSelectionChangeEvent): void; + /** + * The row selection mode. Defaults to `'single'`. + */ + selectionMode?: 'single' | 'multiple'; + + /** + * The currently selected rows, for `selectionMode='multiple'`. + */ + selectedItems?: TDataType[]; + + /** + * Callback for when a multiple selection changes, with the full selected set. + */ + onSelectedItemsChange?(items: TDataType[]): void; + /** * Fields to use for global filtering */ @@ -206,9 +221,11 @@ export const DataTableForQuery = < data={rows} dataKey={props.dataKey} emptyMessage={emptyMessage} - selectionMode='single' + selectionMode={props.selectionMode ?? 'single'} selection={props.selection} onSelectionChange={props.onSelectionChange} + selectedItems={props.selectedItems} + onSelectedItemsChange={props.onSelectedItemsChange} globalFilterFields={props.globalFilterFields} globalSearchPlaceholder={props.globalSearchPlaceholder} globalSearchAriaLabel={props.globalSearchAriaLabel} diff --git a/Source/DataTables/for_ColumnFilterMenu/when_filtering_over_a_known_set_of_values.tsx b/Source/DataTables/for_ColumnFilterMenu/when_filtering_over_a_known_set_of_values.tsx new file mode 100644 index 00000000..92501a1e --- /dev/null +++ b/Source/DataTables/for_ColumnFilterMenu/when_filtering_over_a_known_set_of_values.tsx @@ -0,0 +1,67 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it, vi } from 'vitest'; +import type { DataTableFilterMeta } from '../DataTableFilterMeta'; +import { + type FilterableTableInTheDom, + openFilterMenu, + renderFilterableTable, + unmountFilterableTable, +} from './given/a_filterable_table'; + +const onFilter = vi.fn<(filters: DataTableFilterMeta) => void>(); + +describe('when filtering over a known set of values', () => { + let table: FilterableTableInTheDom; + let menu: HTMLElement; + + beforeEach(async () => { + onFilter.mockClear(); + table = await renderFilterableTable({ + onFilter, + column: { + field: 'role', + filterField: 'roleCode', + header: 'Role', + filter: true, + dataType: 'text', + showFilterMatchModes: true, + filterOptions: [ + { label: 'Administrator', value: 'admin' }, + { label: 'Advisor', value: 'advisor' }, + ], + }, + }); + menu = await openFilterMenu(table); + }); + + afterEach(async () => { + await unmountFilterableTable(table); + }); + + it('should offer the values instead of a free-form input', () => { + expect(menu.querySelector('.cratis-filter-menu__input')).to.equal(null); + }); + + it('should offer every supplied value', () => { + const labels = [...menu.querySelectorAll('option')].map((option) => + option.textContent?.trim(), + ); + expect(labels).to.contain('Administrator'); + expect(labels).to.contain('Advisor'); + }); + + it('should narrow the match modes to equality', () => { + const labels = [...menu.querySelectorAll('option')].map((option) => + option.textContent?.trim(), + ); + expect(labels).to.contain('Equals'); + expect(labels).to.contain('Not equals'); + expect(labels).not.to.contain('Starts with'); + expect(labels).not.to.contain('Contains'); + }); +}); diff --git a/Source/DataTables/for_DataTableCore/when_selecting_multiple_rows.tsx b/Source/DataTables/for_DataTableCore/when_selecting_multiple_rows.tsx new file mode 100644 index 00000000..e3a555d7 --- /dev/null +++ b/Source/DataTables/for_DataTableCore/when_selecting_multiple_rows.tsx @@ -0,0 +1,120 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { expect } from 'chai'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { Column } from '../Column'; +import { DataTableCore } from '../DataTableCore'; + +interface Person { + id: string; + name: string; +} + +const people: Person[] = [ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + { id: '3', name: 'Radia' }, +]; + +describe('when selecting multiple rows', () => { + let container: HTMLDivElement; + let root: Root; + let selected: Person[]; + + const render = async (globalFilterFields?: string[]) => { + await act(async () => { + root.render( + + data={people} + dataKey='id' + emptyMessage='No people' + selectionMode='multiple' + selectedItems={selected} + onSelectedItemsChange={(items) => { + selected = items; + }} + globalFilterFields={globalFilterFields} + > + selectionMode='multiple' /> + field='name' header='Name' /> + , + ); + }); + }; + + const selectAll = () => + container.querySelector('[data-cratis-part="select-all"]')!; + const rowCheckboxes = () => + Array.from( + container.querySelectorAll( + '[data-cratis-part="cell"] input[type="checkbox"]', + ), + ); + + beforeEach(async () => { + ( + globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + selected = []; + await render(); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it('should render a checkbox per row and a select-all in the header', () => { + expect(rowCheckboxes()).to.have.lengthOf(3); + expect(selectAll()).not.to.equal(null); + }); + + it('should report the row when its checkbox is ticked', async () => { + await act(async () => rowCheckboxes()[1].click()); + expect(selected.map((person) => person.id)).to.deep.equal(['2']); + }); + + it('should report every row when select-all is ticked', async () => { + await act(async () => selectAll().click()); + expect(selected.map((person) => person.id)).to.deep.equal(['1', '2', '3']); + }); + + it('should clear the selection when select-all is ticked while everything is selected', async () => { + selected = [...people]; + await render(); + await act(async () => selectAll().click()); + expect(selected).to.have.lengthOf(0); + }); + + it('should show the select-all as indeterminate for a partial selection', async () => { + selected = [people[0]]; + await render(); + expect(selectAll().indeterminate).to.equal(true); + expect(selectAll().checked).to.equal(false); + }); + + it('should select only the rows the filter leaves visible', async () => { + await render(['name']); + const search = container.querySelector( + '[data-cratis-part="search-input"]', + )!; + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + setValue.call(search, 'Ada'); + search.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => selectAll().click()); + expect(selected.map((person) => person.id)).to.deep.equal(['1']); + }); +}); diff --git a/Source/configuration/CratisComponentsDefaults.ts b/Source/configuration/CratisComponentsDefaults.ts index 3b82c979..3197fbf5 100644 --- a/Source/configuration/CratisComponentsDefaults.ts +++ b/Source/configuration/CratisComponentsDefaults.ts @@ -44,6 +44,7 @@ export const cratisDefaults: CratisComponentsConfig = { }, dataTable: { selectRow: 'Select row', + selectAllRows: 'Select all rows', search: 'Search…', searchAriaLabel: 'Search table', }, From 21a5fc7d8090114255118ac291cb9fb750970118 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 24 Sep 2026 15:30:31 +0200 Subject: [PATCH 2/3] Declare the select-all part and update the pinned story count The parts manifest is the contract for what a component emits, so a new data-cratis-part has to be declared there; the pinned Storybook index count moves by the two stories added for the new filter and selection behavior. --- Source/scripts/generate-parts.mjs | 2 ++ Source/types/parts.ts | 9 ++++++--- Storybook/scripts/verify-storybook-indexes.mjs | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Source/scripts/generate-parts.mjs b/Source/scripts/generate-parts.mjs index 6622145d..c81f9bdf 100644 --- a/Source/scripts/generate-parts.mjs +++ b/Source/scripts/generate-parts.mjs @@ -374,6 +374,7 @@ export const partDefinitions = { 'header-cell', 'header-content', 'sort', + 'select-all', 'body', 'empty-row', 'empty-cell', @@ -782,6 +783,7 @@ export const partStateDefinitions = { 'header-cell': ['selected'], 'header-content': ['selected'], sort: ['pressed'], + 'select-all': [], body: [], 'empty-row': [], 'empty-cell': [], diff --git a/Source/types/parts.ts b/Source/types/parts.ts index 8a3e3268..8c29e464 100644 --- a/Source/types/parts.ts +++ b/Source/types/parts.ts @@ -42,9 +42,9 @@ export const cratisParts = { Dialog: ['backdrop', 'positioner', 'root', 'header', 'title', 'subtitle', 'close', 'content', 'busy-scope', 'footer', 'confirm', 'cancel'], CommandDialog: ['backdrop', 'positioner', 'root', 'header', 'title', 'subtitle', 'close', 'content', 'busy-scope', 'footer', 'confirm', 'cancel'], CommandStepper: ['root', 'list', 'step', 'header', 'number', 'title', 'separator', 'panels', 'panel'], - DataTableCore: ['root', 'search', 'search-input', 'table-container', 'table', 'head', 'header-row', 'header-cell', 'header-content', 'sort', 'body', 'empty-row', 'empty-cell', 'row', 'cell'], - DataTableForObservableQuery: ['root', 'search', 'search-input', 'table-container', 'table', 'head', 'header-row', 'header-cell', 'header-content', 'sort', 'body', 'empty-row', 'empty-cell', 'row', 'cell'], - DataTableForQuery: ['root', 'search', 'search-input', 'table-container', 'table', 'head', 'header-row', 'header-cell', 'header-content', 'sort', 'body', 'empty-row', 'empty-cell', 'row', 'cell'], + DataTableCore: ['root', 'search', 'search-input', 'table-container', 'table', 'head', 'header-row', 'header-cell', 'header-content', 'sort', 'select-all', 'body', 'empty-row', 'empty-cell', 'row', 'cell'], + DataTableForObservableQuery: ['root', 'search', 'search-input', 'table-container', 'table', 'head', 'header-row', 'header-cell', 'header-content', 'sort', 'select-all', 'body', 'empty-row', 'empty-cell', 'row', 'cell'], + DataTableForQuery: ['root', 'search', 'search-input', 'table-container', 'table', 'head', 'header-row', 'header-cell', 'header-content', 'sort', 'select-all', 'body', 'empty-row', 'empty-cell', 'row', 'cell'], ColumnFilterMenu: ['filter-trigger', 'filter-popover', 'filter-menu', 'filter-actions'], TablePaginator: ['root', 'range', 'info'], Toaster: ['region', 'toast', 'icon', 'content', 'title', 'description', 'close', 'action'], @@ -386,6 +386,7 @@ export const cratisPartStates = { 'header-cell': ['selected'], 'header-content': ['selected'], 'sort': ['pressed'], + 'select-all': [], 'body': [], 'empty-row': [], 'empty-cell': [], @@ -403,6 +404,7 @@ export const cratisPartStates = { 'header-cell': ['selected'], 'header-content': ['selected'], 'sort': ['pressed'], + 'select-all': [], 'body': [], 'empty-row': [], 'empty-cell': [], @@ -420,6 +422,7 @@ export const cratisPartStates = { 'header-cell': ['selected'], 'header-content': ['selected'], 'sort': ['pressed'], + 'select-all': [], 'body': [], 'empty-row': [], 'empty-cell': [], diff --git a/Storybook/scripts/verify-storybook-indexes.mjs b/Storybook/scripts/verify-storybook-indexes.mjs index 76acaf1e..69b562c3 100644 --- a/Storybook/scripts/verify-storybook-indexes.mjs +++ b/Storybook/scripts/verify-storybook-indexes.mjs @@ -41,8 +41,8 @@ for (const adapter of inventory.adapters) { const entries = Object.values(index.entries ?? {}); const storyIds = entries.filter(entry => entry.type === 'story').map(entry => entry.id).sort(); const docsIds = entries.filter(entry => entry.type === 'docs').map(entry => entry.id).sort(); - if (storyIds.length !== 328 || docsIds.length !== 74) { - throw new Error(`${adapter.metadata.id} indexed ${storyIds.length} stories and ${docsIds.length} autodocs pages; expected 328 and 74.`); + if (storyIds.length !== 330 || docsIds.length !== 74) { + throw new Error(`${adapter.metadata.id} indexed ${storyIds.length} stories and ${docsIds.length} autodocs pages; expected 330 and 74.`); } canonicalStoryIds ??= storyIds; canonicalDocsIds ??= docsIds; From 0f94e2de1f822f8263133a22051ca696c2532077 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 24 Sep 2026 15:39:19 +0200 Subject: [PATCH 3/3] Move the pinned renderer-matrix story count for the two new stories --- Storybook/scripts/verify-storybook-indexes.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Storybook/scripts/verify-storybook-indexes.mjs b/Storybook/scripts/verify-storybook-indexes.mjs index 69b562c3..ace79673 100644 --- a/Storybook/scripts/verify-storybook-indexes.mjs +++ b/Storybook/scripts/verify-storybook-indexes.mjs @@ -86,8 +86,8 @@ const { slotOwningModules, matrixStoryIds } = computeRendererMatrixScope({ if (slotOwningModules.size !== 14) { throw new Error(`Expected 14 slot-owning modules (the stable nine-slot presentation profile plus experimental slots), found ${slotOwningModules.size}.`); } -if (matrixStoryIds.size !== 176) { - throw new Error(`Expected 176 stories to require the full renderer matrix, found ${matrixStoryIds.size}. If this is an intentional consequence of adding or removing a slotted/composite component, update this pinned count.`); +if (matrixStoryIds.size !== 178) { + throw new Error(`Expected 178 stories to require the full renderer matrix, found ${matrixStoryIds.size}. If this is an intentional consequence of adding or removing a slotted/composite component, update this pinned count.`); } const appearances = 2;