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', }, 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..ace79673 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; @@ -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;