diff --git a/Source/DataTables/DataTableBody.tsx b/Source/DataTables/DataTableBody.tsx
new file mode 100644
index 00000000..840817a2
--- /dev/null
+++ b/Source/DataTables/DataTableBody.tsx
@@ -0,0 +1,116 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { ReactNode } from 'react';
+import { classNames } from './classNames';
+import type { DataTableMessages } from './DataTableMessages';
+import type { DataTableParts } from './DataTableParts';
+import { DataTableStatus } from './DataTableStatus';
+
+/** Props for {@link DataTableBody}. */
+export interface DataTableBodyProps {
+ status: DataTableStatus;
+ /** Whether the table was given any rows, before filtering. */
+ hasLoadedRows: boolean;
+ /** How many rows are left to render after filtering. */
+ rowCount: number;
+ columnCount: number;
+ /** The table's part attributes. */
+ parts?: DataTableParts;
+ messages: DataTableMessages;
+ emptyMessage: ReactNode;
+ /** Renders the data rows; called only when there are rows to show. */
+ renderRows: () => ReactNode;
+}
+
+/** The table body: a failure, loading or empty message row, or the data rows. */
+export const DataTableBody = ({
+ status,
+ hasLoadedRows,
+ rowCount,
+ columnCount,
+ parts: pt,
+ messages,
+ emptyMessage,
+ renderRows,
+}: DataTableBodyProps) => (
+
+ {status === DataTableStatus.Failed || status === DataTableStatus.Unauthorized ? (
+
+
+
+ {status === DataTableStatus.Failed
+ ? messages.resolvedFailureMessage
+ : messages.resolvedUnauthorizedMessage}
+
+
+
+ ) : status === DataTableStatus.Loading && !hasLoadedRows ? (
+
+
+ {messages.resolvedLoadingMessage}
+
+
+ ) : rowCount === 0 ? (
+
+
+ {emptyMessage}
+
+
+ ) : (
+ renderRows()
+ )}
+
+);
diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx
index ba5bac61..62f063d0 100644
--- a/Source/DataTables/DataTableCore.tsx
+++ b/Source/DataTables/DataTableCore.tsx
@@ -1,83 +1,39 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-import React, {
- useEffect,
- useId,
- useMemo,
- useRef,
- type CSSProperties,
- type HTMLAttributes,
- type ReactNode,
- type TableHTMLAttributes,
- type TdHTMLAttributes,
- type ThHTMLAttributes,
-} from 'react';
-import { useCratisComponentsConfig } from '../Common/CratisComponentsProvider';
+import React, { useId, useMemo, type CSSProperties, type ReactNode } from 'react';
import { useCratisIcon } from '../configuration/useCratisIcon';
import type { ColumnProps } from './Column';
-import { ColumnFilterMenu } from './ColumnFilterMenu';
+import { classNames } from './classNames';
+import type { DataTableFilterMeta } from './DataTableFilterMeta';
+import { DataTableHeaderCell } from './DataTableHeaderCell';
+import type { DataTableParts } from './DataTableParts';
+import type { DataTableRowClickEvent } from './DataTableRowClickEvent';
+import { DataTableBody } from './DataTableBody';
+import { DataTableRow } from './DataTableRow';
+import { DataTableSearch } from './DataTableSearch';
+import { DataTableRowProcessing } from './DataTableRowProcessing';
import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent';
-import {
- DataTableFilterMatchMode,
- type DataTableFilterConstraint,
- type DataTableFilterEntry,
- type DataTableFilterMeta,
-} from './DataTableFilterMeta';
-import { resolveDataTableFilterMatcher } from './DataTableFilterMatcherRegistry';
-import { DataTableStatus } from './DataTableStatus';
import type { DataTableSort } from './DataTableSort';
-import { DataTableSortDirection } from './DataTableSortDirection';
-import { DataTableRowProcessing } from './DataTableRowProcessing';
-import { useControllableState } from './useControllableState';
+import { DataTableStatus } from './DataTableStatus';
+import { processRows } from './processRows';
+import { useDataTableMessages } from './useDataTableMessages';
+import { useDataTableState } from './useDataTableState';
+import { useRowSelection } from './useRowSelection';
-/* eslint-disable @typescript-eslint/no-explicit-any */
+export type { DataTableParts } from './DataTableParts';
+export type { DataTableRowClickEvent } from './DataTableRowClickEvent';
-/** Event emitted when a DataTable row is activated. */
-export interface DataTableRowClickEvent {
- /** Activated row data. */
- data: TData;
- /** Loaded-page row index. */
- index: number;
-}
+/* eslint-disable @typescript-eslint/no-explicit-any */
-/** Stable Cratis-owned parts for styling a {@link DataTableCore}. */
-export interface DataTableParts {
- /** Outer table composition. */
- root?: HTMLAttributes;
- /** Loaded-page search wrapper. */
- search?: HTMLAttributes;
- /** Loaded-page search input. */
- searchInput?: React.InputHTMLAttributes;
- /** Scroll container. */
- tableContainer?: HTMLAttributes;
- /** Semantic table element. */
- table?: TableHTMLAttributes;
- /** Table head. */
- head?: HTMLAttributes;
- /** Header row. */
- headerRow?: HTMLAttributes;
- /** Header cell. */
- headerCell?: ThHTMLAttributes;
- /** Table body. */
- body?: HTMLAttributes;
- /** Data row. */
- row?: HTMLAttributes;
- /** Data cell. */
- cell?: TdHTMLAttributes;
- /** Empty-state row. */
- emptyRow?: HTMLAttributes;
- /** Empty-state cell. */
- emptyCell?: TdHTMLAttributes;
- /** Loading-state row. */
- loadingRow?: HTMLAttributes;
- /** Loading-state cell. */
- loadingCell?: TdHTMLAttributes;
- /** Failed or unauthorized row. */
- failureRow?: HTMLAttributes;
- /** Failed or unauthorized cell. */
- failureCell?: TdHTMLAttributes;
-}
+const useColumns = (children: React.ReactNode): React.ReactElement>[] =>
+ useMemo(
+ () =>
+ React.Children.toArray(children).filter(
+ React.isValidElement,
+ ) as React.ReactElement>[],
+ [children],
+ );
/** Props for the semantic loaded-page DataTable renderer. */
export interface DataTableCoreProps {
@@ -165,139 +121,6 @@ export interface DataTableCoreProps {
unstyled?: boolean;
}
-const useColumns = (children: ReactNode): React.ReactElement>[] =>
- useMemo(
- () =>
- React.Children.toArray(children).filter(
- React.isValidElement,
- ) as React.ReactElement>[],
- [children],
- );
-
-type CellValue =
- string | number | boolean | bigint | symbol | Date | object | null | undefined;
-
-const asCellValue = (value: unknown): CellValue =>
- typeof value === 'function' ? String(value) : (value as CellValue);
-
-const valueAtPath = (
- row: Record,
- path: string | undefined,
-): CellValue => {
- if (!path) return undefined;
- let current: CellValue = row;
- for (const segment of path.split('.')) {
- if (current === null || typeof current !== 'object') return undefined;
- const record = current as Record;
- if (!Object.hasOwn(record, segment)) return undefined;
- current = asCellValue(record[segment]);
- }
- return current;
-};
-
-const renderCellContent = (
- column: ColumnProps,
- row: Record,
-): ReactNode => {
- if (column.body) return column.body(row);
- const value = valueAtPath(row, column.field);
- return value == null ? '' : String(value);
-};
-
-const dateNumber = (value: unknown) => {
- // A cloned Date is normalized in place so caller-owned Date instances (row
- // values and filter constraints alike) are never mutated by comparison.
- const date =
- value instanceof Date ? new Date(value.getTime()) : new Date(String(value));
- return Number.isNaN(date.getTime()) ? undefined : date.setHours(0, 0, 0, 0);
-};
-
-const firstConstraint = (
- entry: DataTableFilterEntry | undefined,
-): DataTableFilterConstraint | undefined =>
- entry && 'constraints' in entry ? entry.constraints[0] : entry;
-
-const builtInMatches = (
- value: unknown,
- constraint: DataTableFilterConstraint,
-): boolean => {
- const filter = constraint.value;
- const mode = constraint.matchMode ?? DataTableFilterMatchMode.Contains;
- if (filter === null || filter === undefined || filter === '') return true;
-
- const valueText = String(value ?? '').toLocaleLowerCase();
- const filterText = String(filter).toLocaleLowerCase();
- const valueNumber = typeof value === 'number' ? value : Number(value);
- const filterNumber = typeof filter === 'number' ? filter : Number(filter);
-
- switch (mode) {
- case DataTableFilterMatchMode.StartsWith:
- return valueText.startsWith(filterText);
- case DataTableFilterMatchMode.Contains:
- return valueText.includes(filterText);
- case DataTableFilterMatchMode.NotContains:
- return !valueText.includes(filterText);
- case DataTableFilterMatchMode.EndsWith:
- return valueText.endsWith(filterText);
- case DataTableFilterMatchMode.Equals:
- return Object.is(value, filter) || valueText === filterText;
- case DataTableFilterMatchMode.NotEquals:
- return !(Object.is(value, filter) || valueText === filterText);
- case DataTableFilterMatchMode.In:
- return Array.isArray(filter) && filter.some((item) => Object.is(item, value));
- case DataTableFilterMatchMode.Between:
- return (
- Array.isArray(filter) &&
- filter.length >= 2 &&
- valueNumber >= Number(filter[0]) &&
- valueNumber <= Number(filter[1])
- );
- case DataTableFilterMatchMode.LessThan:
- return valueNumber < filterNumber;
- case DataTableFilterMatchMode.LessThanOrEqual:
- return valueNumber <= filterNumber;
- case DataTableFilterMatchMode.GreaterThan:
- return valueNumber > filterNumber;
- case DataTableFilterMatchMode.GreaterThanOrEqual:
- return valueNumber >= filterNumber;
- case DataTableFilterMatchMode.DateIs:
- return dateNumber(value) === dateNumber(filter);
- case DataTableFilterMatchMode.DateIsNot:
- return dateNumber(value) !== dateNumber(filter);
- case DataTableFilterMatchMode.DateBefore:
- return (dateNumber(value) ?? Infinity) < (dateNumber(filter) ?? -Infinity);
- case DataTableFilterMatchMode.DateAfter:
- return (dateNumber(value) ?? -Infinity) > (dateNumber(filter) ?? Infinity);
- default:
- return resolveDataTableFilterMatcher(String(mode))?.(value, filter) ?? false;
- }
-};
-
-const matchesFilterEntry = (value: unknown, entry: DataTableFilterEntry) => {
- if (!('constraints' in entry)) return builtInMatches(value, entry);
- if (entry.constraints.length === 0) return true;
-
- const matches = entry.constraints.map((constraint) =>
- builtInMatches(value, constraint),
- );
- return entry.operator?.toLowerCase() === 'or'
- ? matches.some(Boolean)
- : matches.every(Boolean);
-};
-
-const compareValues = (left: unknown, right: unknown): number => {
- if (typeof left === 'number' && typeof right === 'number') return left - right;
- if (left instanceof Date && right instanceof Date)
- return left.getTime() - right.getTime();
- return String(left ?? '').localeCompare(String(right ?? ''), undefined, {
- numeric: true,
- sensitivity: 'base',
- });
-};
-
-const classNames = (...values: Array) =>
- values.filter(Boolean).join(' ');
-
/** A semantic, renderer-independent data table over one already-loaded page. */
export const DataTableCore = ({
data,
@@ -334,72 +157,67 @@ export const DataTableCore = ({
style,
pt,
}: DataTableCoreProps) => {
- const { messages } = useCratisComponentsConfig();
- 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 =
- globalSearchAriaLabel ?? dataTableMessages?.searchAriaLabel ?? 'Search table';
- const resolvedLoadingMessage = loadingMessage ?? dataTableMessages?.loading ?? 'Loading…';
- const resolvedFailureMessage =
- failureMessage ?? dataTableMessages?.failed ?? 'Could not load data.';
- const resolvedUnauthorizedMessage =
- unauthorizedMessage ?? dataTableMessages?.unauthorized ??
- 'You are not authorized to view this data.';
+ const messages = useDataTableMessages({
+ selectionAriaLabel,
+ selectAllAriaLabel,
+ globalSearchPlaceholder,
+ globalSearchAriaLabel,
+ loadingMessage,
+ failureMessage,
+ unauthorizedMessage,
+ });
+ const {
+ resolvedSelectionAriaLabel,
+ resolvedSelectAllAriaLabel,
+ resolvedGlobalSearchPlaceholder,
+ resolvedGlobalSearchAriaLabel,
+ } = messages;
const isBusy = status === DataTableStatus.Loading && data.length > 0;
const icon = useCratisIcon();
const sortAscendingIcon = icon('sortAscending', '▲');
const sortDescendingIcon = icon('sortDescending', '▼');
const columns = useColumns(children);
const selectionGroupName = useId();
- const [filters, setFilters] = useControllableState(filtersProp, defaultFilters ?? {}, onFilter);
- const [globalFilter, setGlobalFilter] = useControllableState(globalFilterProp, '', onGlobalFilterChange);
- const [sort, setSort] = useControllableState(sortProp, null, onSortChange);
-
- const filteredRows = useMemo(() => {
- if (rowProcessing === DataTableRowProcessing.None) {
- return data.map((row, loadedIndex) => ({ row, loadedIndex }));
- }
- const term = globalFilter.trim().toLocaleLowerCase();
- const rows = data
- .map((row, loadedIndex) => ({ row, loadedIndex }))
- .filter(({ row }) => {
- const rowValues = row as Record;
- const matchesColumns = Object.entries(filters).every(([field, entry]) =>
- matchesFilterEntry(valueAtPath(rowValues, field), entry),
- );
- if (!matchesColumns) return false;
- if (!term || !globalFilterFields?.length) return true;
- return globalFilterFields.some((field) =>
- String(valueAtPath(rowValues, field) ?? '')
- .toLocaleLowerCase()
- .includes(term),
- );
- });
-
- if (!sort) return rows;
- return [...rows].sort((left, right) => {
- const comparison = compareValues(
- valueAtPath(left.row as Record, sort.field),
- valueAtPath(right.row as Record, sort.field),
- );
- return sort.direction === 'ascending' ? comparison : -comparison;
+ const { filters, updateFilter, globalFilter, setGlobalFilter, sort, setSort } =
+ useDataTableState({
+ defaultFilters,
+ filters: filtersProp,
+ onFilter,
+ globalFilter: globalFilterProp,
+ onGlobalFilterChange,
+ sort: sortProp,
+ onSortChange,
});
- }, [data, filters, globalFilter, globalFilterFields, sort, rowProcessing]);
- const updateFilter = (
- field: string,
- constraint: DataTableFilterConstraint | undefined,
- ) => {
- const next = { ...filters };
- if (constraint) next[field] = constraint;
- else delete next[field];
- setFilters(next);
- };
+ const filteredRows = useMemo(
+ () =>
+ processRows(data, {
+ filters,
+ globalFilter,
+ globalFilterFields,
+ sort,
+ rowProcessing,
+ }),
+ [data, filters, globalFilter, globalFilterFields, sort, rowProcessing],
+ );
+ const {
+ dataKeyIdentity,
+ isSelectedRow,
+ isRowSelected,
+ toggleRowSelection,
+ allFilteredRowsSelected,
+ selectAllRef,
+ toggleSelectAll,
+ } = useRowSelection({
+ data,
+ visibleRows: filteredRows.map(({ row }) => row),
+ dataKey,
+ selection,
+ selectedItems,
+ onSelectedItemsChange,
+ });
+ const isInteractive =
+ Boolean(onRowClick) || selectionMode === 'single' || selectionMode === 'multiple';
const activateRow = (
row: TData,
@@ -415,94 +233,6 @@ export const DataTableCore = ({
}
};
- const dataKeyIdentity = (row: TData) =>
- String(valueAtPath(row as Record, dataKey!));
- const dataKeyCounts = useMemo(() => {
- const counts = new Map();
- if (!dataKey) return counts;
- for (const row of data) {
- const identity = String(valueAtPath(row as Record, dataKey));
- counts.set(identity, (counts.get(identity) ?? 0) + 1);
- }
- return counts;
- }, [data, dataKey]);
- const firstLoadedIndexByDataKey = useMemo(() => {
- const indices = new Map();
- if (!dataKey) return indices;
- data.forEach((row, loadedIndex) => {
- const identity = String(valueAtPath(row as Record, dataKey));
- if (!indices.has(identity)) indices.set(identity, loadedIndex);
- });
- return indices;
- }, [data, dataKey]);
- const selectedLoadedIndex = selection ? data.indexOf(selection) : -1;
- const selectedDataKey = selection && dataKey ? dataKeyIdentity(selection) : undefined;
- const isSelectedRow = (row: TData, loadedIndex: number) => {
- if (!selection) return false;
- if (!dataKey) return loadedIndex === selectedLoadedIndex;
-
- const identity = dataKeyIdentity(row);
- if (identity !== selectedDataKey) return false;
- if ((dataKeyCounts.get(identity) ?? 0) <= 1) return true;
- if (selectedLoadedIndex >= 0) return loadedIndex === selectedLoadedIndex;
- 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-busy={isBusy || undefined}
>
{!!globalFilterFields?.length && (
-
- setGlobalFilter(event.target.value)}
- />
-
+
)}
({
)}
data-cratis-part='header-row'
>
- {columns.map((column, index) => {
- const field =
- column.props.filterField ?? column.props.field;
- const ariaSort =
- sort && sort.field === column.props.field
- ? sort.direction
- : undefined;
- return (
-
-
- {column.props.selectionMode ===
- 'multiple' ? (
-
- ) : (
- column.props.selectionMode && (
-
- {resolvedSelectionAriaLabel}
-
- )
- )}
- {column.props.sortable &&
- column.props.field ? (
-
- setSort({
- field: column.props
- .field as string,
- direction:
- sort?.field ===
- column.props.field &&
- sort?.direction ===
- DataTableSortDirection.Ascending
- ? DataTableSortDirection.Descending
- : DataTableSortDirection.Ascending,
- })
- }
- >
- {column.props.header}
- {ariaSort && (
-
- {ariaSort === 'ascending'
- ? sortAscendingIcon
- : sortDescendingIcon}
-
- )}
-
- ) : (
- column.props.header
- )}
- {column.props.filter && field && (
-
- updateFilter(field, constraint)
- }
- onClear={() =>
- updateFilter(field, undefined)
- }
- />
- )}
-
-
- );
- })}
+ {columns.map((column, index) => (
+
+ ))}
-
- {status === DataTableStatus.Failed ||
- status === DataTableStatus.Unauthorized ? (
-
-
-
- {status === DataTableStatus.Failed
- ? resolvedFailureMessage
- : resolvedUnauthorizedMessage}
-
-
-
- ) : status === DataTableStatus.Loading && data.length === 0 ? (
-
-
- {resolvedLoadingMessage}
-
-
- ) : filteredRows.length === 0 ? (
-
-
- {emptyMessage}
-
-
- ) : (
+ 0}
+ rowCount={filteredRows.length}
+ columnCount={columns.length}
+ parts={pt}
+ messages={messages}
+ emptyMessage={emptyMessage}
+ renderRows={() =>
filteredRows.map(({ row, loadedIndex }) => {
const identity = dataKey
? dataKeyIdentity(row)
: 'loaded-row';
- const rowKey = `${identity}::${loadedIndex}`;
const isSelected =
selectionMode === 'single' &&
isSelectedRow(row, loadedIndex);
- const isInteractive =
- Boolean(onRowClick) ||
- selectionMode === 'single' ||
- selectionMode === 'multiple';
return (
-
+ key={`${identity}::${loadedIndex}`}
+ row={row}
+ columns={columns}
+ parts={pt}
+ selectionMode={selectionMode}
+ isSelected={isSelected}
+ isInteractive={isInteractive}
+ isRowSelected={isRowSelected(row)}
+ rowClassName={rowClassName}
+ resolvedSelectionAriaLabel={
+ resolvedSelectionAriaLabel
}
- className={classNames(
- 'cratis-datatable__row',
- pt?.row?.className,
- rowClassName?.(row),
- )}
- data-cratis-part='row'
- data-selected={isSelected || undefined}
- data-interactive={isInteractive || undefined}
- onClick={(event) =>
+ selectionGroupName={selectionGroupName}
+ onActivate={(event) =>
activateRow(row, loadedIndex, event)
}
- onKeyDown={(event) => {
- if (
- event.target !== event.currentTarget ||
- (event.key !== 'Enter' &&
- event.key !== ' ')
- ) {
- return;
- }
- event.preventDefault();
- activateRow(row, loadedIndex, event);
- }}
- >
- {columns.map((column, columnIndex) => (
-
- {column.props.selectionMode ===
- 'multiple' ? (
-
- event.stopPropagation()
- }
- onChange={() =>
- toggleRowSelection(row)
- }
- />
- ) : column.props.selectionMode ? (
-
- ) : (
- renderCellContent(
- column.props,
- row as Record,
- )
- )}
-
- ))}
-
+ onToggleSelection={() => toggleRowSelection(row)}
+ />
);
})
- )}
-
+ }
+ />
diff --git a/Source/DataTables/DataTableHeaderCell.tsx b/Source/DataTables/DataTableHeaderCell.tsx
new file mode 100644
index 00000000..d22ef243
--- /dev/null
+++ b/Source/DataTables/DataTableHeaderCell.tsx
@@ -0,0 +1,148 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { ReactElement, ReactNode, RefObject } from 'react';
+import type { ColumnProps } from './Column';
+import { ColumnFilterMenu } from './ColumnFilterMenu';
+import { classNames } from './classNames';
+import type {
+ DataTableFilterConstraint,
+ DataTableFilterMeta,
+} from './DataTableFilterMeta';
+import type { DataTableParts } from './DataTableParts';
+import type { DataTableSort } from './DataTableSort';
+import { DataTableSortDirection } from './DataTableSortDirection';
+import { firstConstraint } from './matchesFilterEntry';
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/** Props for {@link DataTableHeaderCell}. */
+export interface DataTableHeaderCellProps {
+ column: ReactElement>;
+ /** The table's part attributes. */
+ parts?: DataTableParts;
+ sort: DataTableSort | null;
+ onSort: (sort: DataTableSort) => void;
+ filters: DataTableFilterMeta;
+ updateFilter: (
+ field: string,
+ constraint: DataTableFilterConstraint | undefined,
+ ) => void;
+ resolvedSelectionAriaLabel: string;
+ resolvedSelectAllAriaLabel: string;
+ allFilteredRowsSelected: boolean;
+ selectAllRef: RefObject;
+ toggleSelectAll: () => void;
+ sortAscendingIcon: ReactNode;
+ sortDescendingIcon: ReactNode;
+}
+
+/** One column header: its label, sort control, filter menu, and select-all or selection label. */
+export const DataTableHeaderCell = ({
+ column,
+ parts: pt,
+ sort,
+ onSort,
+ filters,
+ updateFilter,
+ resolvedSelectionAriaLabel,
+ resolvedSelectAllAriaLabel,
+ allFilteredRowsSelected,
+ selectAllRef,
+ toggleSelectAll,
+ sortAscendingIcon,
+ sortDescendingIcon,
+}: DataTableHeaderCellProps) => {
+ const field = column.props.filterField ?? column.props.field;
+ const ariaSort =
+ sort && sort.field === column.props.field ? sort.direction : undefined;
+ return (
+
+
+ {column.props.selectionMode === 'multiple' ? (
+
+ ) : (
+ column.props.selectionMode && (
+
+ {resolvedSelectionAriaLabel}
+
+ )
+ )}
+ {column.props.sortable && column.props.field ? (
+
+ onSort({
+ field: column.props.field as string,
+ direction:
+ sort?.field === column.props.field &&
+ sort?.direction === DataTableSortDirection.Ascending
+ ? DataTableSortDirection.Descending
+ : DataTableSortDirection.Ascending,
+ })
+ }
+ >
+ {column.props.header}
+ {ariaSort && (
+
+ {ariaSort === 'ascending'
+ ? sortAscendingIcon
+ : sortDescendingIcon}
+
+ )}
+
+ ) : (
+ column.props.header
+ )}
+ {column.props.filter && field && (
+ updateFilter(field, constraint)}
+ onClear={() => updateFilter(field, undefined)}
+ />
+ )}
+
+
+ );
+};
diff --git a/Source/DataTables/DataTableMessageOverrides.ts b/Source/DataTables/DataTableMessageOverrides.ts
new file mode 100644
index 00000000..1cf3861d
--- /dev/null
+++ b/Source/DataTables/DataTableMessageOverrides.ts
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { ReactNode } from 'react';
+
+/** Table messages passed as props, each overriding the provider's message. */
+export interface DataTableMessageOverrides {
+ selectionAriaLabel?: string;
+ selectAllAriaLabel?: string;
+ globalSearchPlaceholder?: string;
+ globalSearchAriaLabel?: string;
+ loadingMessage?: ReactNode;
+ failureMessage?: ReactNode;
+ unauthorizedMessage?: ReactNode;
+}
diff --git a/Source/DataTables/DataTableMessages.ts b/Source/DataTables/DataTableMessages.ts
new file mode 100644
index 00000000..b5e1fc09
--- /dev/null
+++ b/Source/DataTables/DataTableMessages.ts
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { ReactNode } from 'react';
+
+/** The table messages to show, after props and provider messages are applied. */
+export interface DataTableMessages {
+ resolvedSelectionAriaLabel: string;
+ resolvedSelectAllAriaLabel: string;
+ resolvedGlobalSearchPlaceholder: string;
+ resolvedGlobalSearchAriaLabel: string;
+ resolvedLoadingMessage: ReactNode;
+ resolvedFailureMessage: ReactNode;
+ resolvedUnauthorizedMessage: ReactNode;
+}
diff --git a/Source/DataTables/DataTableParts.ts b/Source/DataTables/DataTableParts.ts
new file mode 100644
index 00000000..68ec8c02
--- /dev/null
+++ b/Source/DataTables/DataTableParts.ts
@@ -0,0 +1,48 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type React from 'react';
+import type {
+ HTMLAttributes,
+ TableHTMLAttributes,
+ TdHTMLAttributes,
+ ThHTMLAttributes,
+} from 'react';
+
+/** Stable Cratis-owned parts for styling a `DataTableCore`. */
+export interface DataTableParts {
+ /** Outer table composition. */
+ root?: HTMLAttributes;
+ /** Loaded-page search wrapper. */
+ search?: HTMLAttributes;
+ /** Loaded-page search input. */
+ searchInput?: React.InputHTMLAttributes;
+ /** Scroll container. */
+ tableContainer?: HTMLAttributes;
+ /** Semantic table element. */
+ table?: TableHTMLAttributes;
+ /** Table head. */
+ head?: HTMLAttributes;
+ /** Header row. */
+ headerRow?: HTMLAttributes;
+ /** Header cell. */
+ headerCell?: ThHTMLAttributes;
+ /** Table body. */
+ body?: HTMLAttributes;
+ /** Data row. */
+ row?: HTMLAttributes;
+ /** Data cell. */
+ cell?: TdHTMLAttributes;
+ /** Empty-state row. */
+ emptyRow?: HTMLAttributes;
+ /** Empty-state cell. */
+ emptyCell?: TdHTMLAttributes;
+ /** Loading-state row. */
+ loadingRow?: HTMLAttributes;
+ /** Loading-state cell. */
+ loadingCell?: TdHTMLAttributes;
+ /** Failed or unauthorized row. */
+ failureRow?: HTMLAttributes;
+ /** Failed or unauthorized cell. */
+ failureCell?: TdHTMLAttributes;
+}
diff --git a/Source/DataTables/DataTableRow.tsx b/Source/DataTables/DataTableRow.tsx
new file mode 100644
index 00000000..183c39e0
--- /dev/null
+++ b/Source/DataTables/DataTableRow.tsx
@@ -0,0 +1,117 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { ReactElement, ReactNode, SyntheticEvent } from 'react';
+import type { ColumnProps } from './Column';
+import { classNames } from './classNames';
+import type { DataTableParts } from './DataTableParts';
+import { valueAtPath } from './valueAtPath';
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const renderCellContent = (
+ column: ColumnProps,
+ row: Record,
+): ReactNode => {
+ if (column.body) return column.body(row);
+ const value = valueAtPath(row, column.field);
+ return value == null ? '' : String(value);
+};
+
+/** Props for {@link DataTableRow}. */
+export interface DataTableRowProps {
+ row: TData;
+ columns: ReactElement>[];
+ /** The table's part attributes. */
+ parts?: DataTableParts;
+ selectionMode?: 'single' | 'multiple';
+ isSelected: boolean;
+ isInteractive: boolean;
+ isRowSelected: boolean;
+ rowClassName?: (rowData: TData) => string;
+ resolvedSelectionAriaLabel: string;
+ selectionGroupName: string;
+ onActivate: (event: SyntheticEvent) => void;
+ onToggleSelection: () => void;
+}
+
+/** One data row, activated by click, Enter or Space. */
+export const DataTableRow = ({
+ row,
+ columns,
+ parts: pt,
+ selectionMode,
+ isSelected,
+ isInteractive,
+ isRowSelected,
+ rowClassName,
+ resolvedSelectionAriaLabel,
+ selectionGroupName,
+ onActivate,
+ onToggleSelection,
+}: DataTableRowProps) => (
+ onActivate(event)}
+ onKeyDown={(event) => {
+ if (
+ event.target !== event.currentTarget ||
+ (event.key !== 'Enter' && event.key !== ' ')
+ ) {
+ return;
+ }
+ event.preventDefault();
+ onActivate(event);
+ }}
+ >
+ {columns.map((column, columnIndex) => (
+
+ {column.props.selectionMode === 'multiple' ? (
+ event.stopPropagation()}
+ onChange={() => onToggleSelection()}
+ />
+ ) : column.props.selectionMode ? (
+
+ ) : (
+ renderCellContent(column.props, row as Record)
+ )}
+
+ ))}
+
+);
diff --git a/Source/DataTables/DataTableRowClickEvent.ts b/Source/DataTables/DataTableRowClickEvent.ts
new file mode 100644
index 00000000..afb01bf6
--- /dev/null
+++ b/Source/DataTables/DataTableRowClickEvent.ts
@@ -0,0 +1,10 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/** Event emitted when a DataTable row is activated. */
+export interface DataTableRowClickEvent {
+ /** Activated row data. */
+ data: TData;
+ /** Loaded-page row index. */
+ index: number;
+}
diff --git a/Source/DataTables/DataTableSearch.tsx b/Source/DataTables/DataTableSearch.tsx
new file mode 100644
index 00000000..688c2efb
--- /dev/null
+++ b/Source/DataTables/DataTableSearch.tsx
@@ -0,0 +1,43 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { classNames } from './classNames';
+import type { DataTableParts } from './DataTableParts';
+
+/** Props for {@link DataTableSearch}. */
+export interface DataTableSearchProps {
+ /** The table's part attributes. */
+ parts?: DataTableParts;
+ value: string;
+ placeholder: string;
+ ariaLabel: string;
+ onChange: (value: string) => void;
+}
+
+/** The search box above a table that has search fields. */
+export const DataTableSearch = ({
+ parts: pt,
+ value,
+ placeholder,
+ ariaLabel,
+ onChange,
+}: DataTableSearchProps) => (
+
+ onChange(event.target.value)}
+ />
+
+);
diff --git a/Source/DataTables/DataTableState.ts b/Source/DataTables/DataTableState.ts
new file mode 100644
index 00000000..9d9e2bc6
--- /dev/null
+++ b/Source/DataTables/DataTableState.ts
@@ -0,0 +1,27 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type {
+ DataTableFilterConstraint,
+ DataTableFilterMeta,
+} from './DataTableFilterMeta';
+import type { DataTableSort } from './DataTableSort';
+
+/** A table's current filter, search and sort state, with its setters. */
+export interface DataTableState {
+ /** Applied per-field filters. */
+ filters: DataTableFilterMeta;
+ /** Sets or removes the filter for one field. */
+ updateFilter: (
+ field: string,
+ constraint: DataTableFilterConstraint | undefined,
+ ) => void;
+ /** Search text. */
+ globalFilter: string;
+ /** Sets the search text. */
+ setGlobalFilter: (globalFilter: string) => void;
+ /** Applied sort, or null. */
+ sort: DataTableSort | null;
+ /** Sets the sort. */
+ setSort: (sort: DataTableSort | null) => void;
+}
diff --git a/Source/DataTables/DataTableStateProps.ts b/Source/DataTables/DataTableStateProps.ts
new file mode 100644
index 00000000..744a04a0
--- /dev/null
+++ b/Source/DataTables/DataTableStateProps.ts
@@ -0,0 +1,23 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { DataTableFilterMeta } from './DataTableFilterMeta';
+import type { DataTableSort } from './DataTableSort';
+
+/** The props that control a table's filter, search and sort state. */
+export interface DataTableStateProps {
+ /** Initial per-field filters, used while the filters are not controlled. */
+ defaultFilters?: DataTableFilterMeta;
+ /** Controlled per-field filters. */
+ filters?: DataTableFilterMeta;
+ /** Receives every filter change. */
+ onFilter?: (filters: DataTableFilterMeta) => void;
+ /** Controlled search text. */
+ globalFilter?: string;
+ /** Receives every search text change. */
+ onGlobalFilterChange?: (globalFilter: string) => void;
+ /** Controlled sort; null means not sorted. */
+ sort?: DataTableSort | null;
+ /** Receives every sort change. */
+ onSortChange?: (sort: DataTableSort | null) => void;
+}
diff --git a/Source/DataTables/ProcessedRow.ts b/Source/DataTables/ProcessedRow.ts
new file mode 100644
index 00000000..1fcb773e
--- /dev/null
+++ b/Source/DataTables/ProcessedRow.ts
@@ -0,0 +1,10 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/** A row the table renders, with its index on the loaded page. */
+export interface ProcessedRow {
+ /** The row. */
+ row: TData;
+ /** Its index on the loaded page, which stays stable through filtering and sorting. */
+ loadedIndex: number;
+}
diff --git a/Source/DataTables/RowProcessingState.ts b/Source/DataTables/RowProcessingState.ts
new file mode 100644
index 00000000..95148bbe
--- /dev/null
+++ b/Source/DataTables/RowProcessingState.ts
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { DataTableFilterMeta } from './DataTableFilterMeta';
+import type { DataTableRowProcessing } from './DataTableRowProcessing';
+import type { DataTableSort } from './DataTableSort';
+
+/** The filter, search and sort state a table applies to its loaded rows. */
+export interface RowProcessingState {
+ /** Applied per-field filters. */
+ filters: DataTableFilterMeta;
+ /** Search text. */
+ globalFilter: string;
+ /** Fields the search text matches against. */
+ globalFilterFields?: string[];
+ /** Applied sort, or null. */
+ sort: DataTableSort | null;
+ /** Whether the table processes rows at all. */
+ rowProcessing: DataTableRowProcessing;
+}
diff --git a/Source/DataTables/RowSelection.ts b/Source/DataTables/RowSelection.ts
new file mode 100644
index 00000000..590f9644
--- /dev/null
+++ b/Source/DataTables/RowSelection.ts
@@ -0,0 +1,22 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { RefObject } from 'react';
+
+/** Row selection state and actions for a `DataTableCore`-style table. */
+export interface RowSelection {
+ /** The text identity of a row by its data key. */
+ dataKeyIdentity: (row: TData) => string;
+ /** Whether a row is the single selected row. */
+ isSelectedRow: (row: TData, loadedIndex: number) => boolean;
+ /** Whether a row is among the multiple selected rows. */
+ isRowSelected: (row: TData) => boolean;
+ /** Adds a row to, or removes it from, the multiple selection. */
+ toggleRowSelection: (row: TData) => void;
+ /** Whether every visible row is selected. */
+ allFilteredRowsSelected: boolean;
+ /** The select-all checkbox, whose indeterminate state the hook keeps. */
+ selectAllRef: RefObject;
+ /** Selects every visible row, or clears them when all are selected. */
+ toggleSelectAll: () => void;
+}
diff --git a/Source/DataTables/RowSelectionSource.ts b/Source/DataTables/RowSelectionSource.ts
new file mode 100644
index 00000000..7cc27494
--- /dev/null
+++ b/Source/DataTables/RowSelectionSource.ts
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/** What `useRowSelection` selects from. */
+export interface RowSelectionSource {
+ /** The loaded rows. */
+ data: TData[];
+ /** The rows currently visible after filtering. */
+ visibleRows: TData[];
+ /** The row property used as identity. */
+ dataKey?: string;
+ /** The single selected row. */
+ selection?: TData | null;
+ /** The multiple selected rows. */
+ selectedItems?: TData[];
+ /** Receives the next multiple selection. */
+ onSelectedItemsChange?: (items: TData[]) => void;
+}
diff --git a/Source/DataTables/classNames.ts b/Source/DataTables/classNames.ts
new file mode 100644
index 00000000..195469d3
--- /dev/null
+++ b/Source/DataTables/classNames.ts
@@ -0,0 +1,10 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/**
+ * Joins the class names that are set.
+ * @param values Class names, some possibly undefined.
+ * @returns The space-separated class names.
+ */
+export const classNames = (...values: Array) =>
+ values.filter(Boolean).join(' ');
diff --git a/Source/DataTables/for_matchesFilterEntry/when_matching_filter_entries.ts b/Source/DataTables/for_matchesFilterEntry/when_matching_filter_entries.ts
new file mode 100644
index 00000000..eca1e676
--- /dev/null
+++ b/Source/DataTables/for_matchesFilterEntry/when_matching_filter_entries.ts
@@ -0,0 +1,84 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { DataTableFilterMatchMode } from '../DataTableFilterMeta';
+import { firstConstraint, matchesFilterEntry } from '../matchesFilterEntry';
+
+describe('when matching a constraint with no filter value', () => {
+ it('should match every value', () => {
+ matchesFilterEntry('anything', { value: '', matchMode: DataTableFilterMatchMode.Equals }).should.be.true;
+ });
+});
+
+describe('when matching text without a match mode', () => {
+ it('should match values that contain the text, ignoring case', () => {
+ matchesFilterEntry('Example Project', { value: 'project' }).should.be.true;
+ matchesFilterEntry('Example Project', { value: 'sample' }).should.be.false;
+ });
+});
+
+describe('when matching constraints combined with or', () => {
+ const entry = {
+ operator: 'or',
+ constraints: [
+ { value: 'Ex', matchMode: DataTableFilterMatchMode.StartsWith },
+ { value: 'User', matchMode: DataTableFilterMatchMode.EndsWith },
+ ],
+ };
+ it('should match when either constraint matches', () => {
+ matchesFilterEntry('Sample User', entry).should.be.true;
+ matchesFilterEntry('Demo Assistant', entry).should.be.false;
+ });
+});
+
+describe('when matching constraints combined with and', () => {
+ const entry = {
+ operator: 'and',
+ constraints: [
+ { value: 'Ex', matchMode: DataTableFilterMatchMode.StartsWith },
+ { value: 'Project', matchMode: DataTableFilterMatchMode.EndsWith },
+ ],
+ };
+ it('should match only when every constraint matches', () => {
+ matchesFilterEntry('Example Project', entry).should.be.true;
+ matchesFilterEntry('Example User', entry).should.be.false;
+ });
+});
+
+describe('when matching an entry with no constraints', () => {
+ it('should match every value', () => {
+ matchesFilterEntry('anything', { operator: 'and', constraints: [] }).should.be.true;
+ });
+});
+
+describe('when matching a number between two bounds', () => {
+ it('should include both bounds', () => {
+ const entry = { value: [2, 4], matchMode: DataTableFilterMatchMode.Between };
+ [1, 2, 4, 5].map((value) => matchesFilterEntry(value, entry)).should.deep.equal([false, true, true, false]);
+ });
+});
+
+describe('when matching dates', () => {
+ const filter = new Date(2024, 4, 17, 8, 0, 0, 0);
+ const filterTime = filter.getTime();
+ const value = new Date(2024, 4, 17, 20, 0, 0, 0);
+ const valueTime = value.getTime();
+ const matched = matchesFilterEntry(value, { value: filter, matchMode: DataTableFilterMatchMode.DateIs });
+
+ it('should compare by day', () => {
+ matched.should.be.true;
+ });
+ it('should not change the caller-owned dates', () => {
+ filter.getTime().should.equal(filterTime);
+ value.getTime().should.equal(valueTime);
+ });
+});
+
+describe('when taking the first constraint of an entry', () => {
+ it('should return the constraint itself or the first of several', () => {
+ const constraint = { value: 'a' };
+ firstConstraint(constraint)!.should.equal(constraint);
+ firstConstraint({ operator: 'or', constraints: [constraint, { value: 'b' }] })!.should.equal(constraint);
+ (firstConstraint(undefined) === undefined).should.be.true;
+ });
+});
diff --git a/Source/DataTables/for_processRows/when_processing_rows.ts b/Source/DataTables/for_processRows/when_processing_rows.ts
new file mode 100644
index 00000000..2427e060
--- /dev/null
+++ b/Source/DataTables/for_processRows/when_processing_rows.ts
@@ -0,0 +1,72 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { DataTableFilterMatchMode } from '../DataTableFilterMeta';
+import { DataTableRowProcessing } from '../DataTableRowProcessing';
+import { DataTableSortDirection } from '../DataTableSortDirection';
+import { processRows } from '../processRows';
+import type { RowProcessingState } from '../RowProcessingState';
+
+const rows = [
+ { name: 'Charlie', score: 10, team: { name: 'Blue' } },
+ { name: 'Alpha', score: 9, team: { name: 'Red' } },
+ { name: 'Bravo', score: 100, team: { name: 'Blue' } },
+];
+const state = (overrides: Partial): RowProcessingState => ({
+ filters: {},
+ globalFilter: '',
+ sort: null,
+ rowProcessing: DataTableRowProcessing.Loaded,
+ ...overrides,
+});
+const names = (processed: ReturnType>) => processed.map(({ row }) => row.name);
+
+describe('when processing rows with no state', () => {
+ it('should keep the loaded order and indexes', () => {
+ processRows(rows, state({})).map(({ loadedIndex }) => loadedIndex).should.deep.equal([0, 1, 2]);
+ });
+});
+
+describe('when processing rows with a column filter and search text', () => {
+ const processed = processRows(rows, state({
+ filters: { 'team.name': { value: 'Blue', matchMode: DataTableFilterMatchMode.Equals } },
+ globalFilter: ' BRA ',
+ globalFilterFields: ['name'],
+ }));
+ it('should keep only rows matching both', () => {
+ names(processed).should.deep.equal(['Bravo']);
+ });
+ it('should keep the row\'s loaded index', () => {
+ processed[0].loadedIndex.should.equal(2);
+ });
+});
+
+describe('when processing rows with search text but no search fields', () => {
+ it('should ignore the search text', () => {
+ names(processRows(rows, state({ globalFilter: 'zzz' }))).should.deep.equal(['Charlie', 'Alpha', 'Bravo']);
+ });
+});
+
+describe('when processing rows sorted by a number', () => {
+ it('should sort numerically rather than as text', () => {
+ names(processRows(rows, state({ sort: { field: 'score', direction: DataTableSortDirection.Ascending } })))
+ .should.deep.equal(['Alpha', 'Charlie', 'Bravo']);
+ });
+});
+
+describe('when processing rows sorted descending by a nested field', () => {
+ it('should reverse the order and keep ties in loaded order', () => {
+ names(processRows(rows, state({ sort: { field: 'team.name', direction: DataTableSortDirection.Descending } })))
+ .should.deep.equal(['Alpha', 'Charlie', 'Bravo']);
+ });
+});
+
+describe('when processing rows with row processing off', () => {
+ it('should return the rows as given', () => {
+ names(processRows(rows, state({
+ rowProcessing: DataTableRowProcessing.None,
+ filters: { name: { value: 'zzz' } },
+ sort: { field: 'name', direction: DataTableSortDirection.Ascending },
+ }))).should.deep.equal(['Charlie', 'Alpha', 'Bravo']);
+ });
+});
diff --git a/Source/DataTables/for_valueAtPath/when_reading_a_path.ts b/Source/DataTables/for_valueAtPath/when_reading_a_path.ts
new file mode 100644
index 00000000..a22c12d0
--- /dev/null
+++ b/Source/DataTables/for_valueAtPath/when_reading_a_path.ts
@@ -0,0 +1,35 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { valueAtPath } from '../valueAtPath';
+
+describe('when reading a nested path', () => {
+ it('should return the nested value', () => {
+ (valueAtPath({ address: { city: 'Example City' } }, 'address.city') as string).should.equal('Example City');
+ });
+});
+
+describe('when reading a path with a missing segment', () => {
+ it('should return undefined', () => {
+ (valueAtPath({ address: null }, 'address.city') === undefined).should.be.true;
+ });
+});
+
+describe('when reading a path through an inherited property', () => {
+ it('should not read properties the row does not own', () => {
+ (valueAtPath({}, 'constructor') === undefined).should.be.true;
+ });
+});
+
+describe('when reading a path to a function', () => {
+ it('should return the function as text', () => {
+ const value = valueAtPath({ describe: () => 'value' }, 'describe');
+ (typeof value).should.equal('string');
+ });
+});
+
+describe('when reading without a path', () => {
+ it('should return undefined', () => {
+ (valueAtPath({ name: 'Sample User' }, undefined) === undefined).should.be.true;
+ });
+});
diff --git a/Source/DataTables/matchesFilterEntry.ts b/Source/DataTables/matchesFilterEntry.ts
new file mode 100644
index 00000000..83cbda34
--- /dev/null
+++ b/Source/DataTables/matchesFilterEntry.ts
@@ -0,0 +1,101 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import {
+ DataTableFilterMatchMode,
+ type DataTableFilterConstraint,
+ type DataTableFilterEntry,
+} from './DataTableFilterMeta';
+import { resolveDataTableFilterMatcher } from './DataTableFilterMatcherRegistry';
+
+const dateNumber = (value: unknown) => {
+ // A cloned Date is normalized in place so caller-owned Date instances (row
+ // values and filter constraints alike) are never mutated by comparison.
+ const date =
+ value instanceof Date ? new Date(value.getTime()) : new Date(String(value));
+ return Number.isNaN(date.getTime()) ? undefined : date.setHours(0, 0, 0, 0);
+};
+
+/**
+ * The first constraint of a filter entry, which is what the column filter menu edits.
+ * @param entry The filter entry, if any.
+ * @returns Its first constraint, or undefined.
+ */
+export const firstConstraint = (
+ entry: DataTableFilterEntry | undefined,
+): DataTableFilterConstraint | undefined =>
+ entry && 'constraints' in entry ? entry.constraints[0] : entry;
+
+const builtInMatches = (
+ value: unknown,
+ constraint: DataTableFilterConstraint,
+): boolean => {
+ const filter = constraint.value;
+ const mode = constraint.matchMode ?? DataTableFilterMatchMode.Contains;
+ if (filter === null || filter === undefined || filter === '') return true;
+
+ const valueText = String(value ?? '').toLocaleLowerCase();
+ const filterText = String(filter).toLocaleLowerCase();
+ const valueNumber = typeof value === 'number' ? value : Number(value);
+ const filterNumber = typeof filter === 'number' ? filter : Number(filter);
+
+ switch (mode) {
+ case DataTableFilterMatchMode.StartsWith:
+ return valueText.startsWith(filterText);
+ case DataTableFilterMatchMode.Contains:
+ return valueText.includes(filterText);
+ case DataTableFilterMatchMode.NotContains:
+ return !valueText.includes(filterText);
+ case DataTableFilterMatchMode.EndsWith:
+ return valueText.endsWith(filterText);
+ case DataTableFilterMatchMode.Equals:
+ return Object.is(value, filter) || valueText === filterText;
+ case DataTableFilterMatchMode.NotEquals:
+ return !(Object.is(value, filter) || valueText === filterText);
+ case DataTableFilterMatchMode.In:
+ return Array.isArray(filter) && filter.some((item) => Object.is(item, value));
+ case DataTableFilterMatchMode.Between:
+ return (
+ Array.isArray(filter) &&
+ filter.length >= 2 &&
+ valueNumber >= Number(filter[0]) &&
+ valueNumber <= Number(filter[1])
+ );
+ case DataTableFilterMatchMode.LessThan:
+ return valueNumber < filterNumber;
+ case DataTableFilterMatchMode.LessThanOrEqual:
+ return valueNumber <= filterNumber;
+ case DataTableFilterMatchMode.GreaterThan:
+ return valueNumber > filterNumber;
+ case DataTableFilterMatchMode.GreaterThanOrEqual:
+ return valueNumber >= filterNumber;
+ case DataTableFilterMatchMode.DateIs:
+ return dateNumber(value) === dateNumber(filter);
+ case DataTableFilterMatchMode.DateIsNot:
+ return dateNumber(value) !== dateNumber(filter);
+ case DataTableFilterMatchMode.DateBefore:
+ return (dateNumber(value) ?? Infinity) < (dateNumber(filter) ?? -Infinity);
+ case DataTableFilterMatchMode.DateAfter:
+ return (dateNumber(value) ?? -Infinity) > (dateNumber(filter) ?? Infinity);
+ default:
+ return resolveDataTableFilterMatcher(String(mode))?.(value, filter) ?? false;
+ }
+};
+
+/**
+ * Whether a value satisfies a filter entry, combining its constraints with the entry's operator.
+ * @param value The row value.
+ * @param entry The filter entry.
+ * @returns True when the value matches.
+ */
+export const matchesFilterEntry = (value: unknown, entry: DataTableFilterEntry) => {
+ if (!('constraints' in entry)) return builtInMatches(value, entry);
+ if (entry.constraints.length === 0) return true;
+
+ const matches = entry.constraints.map((constraint) =>
+ builtInMatches(value, constraint),
+ );
+ return entry.operator?.toLowerCase() === 'or'
+ ? matches.some(Boolean)
+ : matches.every(Boolean);
+};
diff --git a/Source/DataTables/processRows.ts b/Source/DataTables/processRows.ts
new file mode 100644
index 00000000..3c9c03ea
--- /dev/null
+++ b/Source/DataTables/processRows.ts
@@ -0,0 +1,64 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { DataTableRowProcessing } from './DataTableRowProcessing';
+import { matchesFilterEntry } from './matchesFilterEntry';
+import type { ProcessedRow } from './ProcessedRow';
+import type { RowProcessingState } from './RowProcessingState';
+import { valueAtPath } from './valueAtPath';
+
+const compareValues = (left: unknown, right: unknown): number => {
+ if (typeof left === 'number' && typeof right === 'number') return left - right;
+ if (left instanceof Date && right instanceof Date)
+ return left.getTime() - right.getTime();
+ return String(left ?? '').localeCompare(String(right ?? ''), undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+};
+
+/**
+ * Filters and sorts loaded rows by the table's state, or keeps them as given when row processing is off.
+ * @param data The loaded rows.
+ * @param state The filter, search and sort state.
+ * @returns The rows to render, each with its loaded-page index.
+ */
+export const processRows = (
+ data: TData[],
+ {
+ filters,
+ globalFilter,
+ globalFilterFields,
+ sort,
+ rowProcessing,
+ }: RowProcessingState,
+): ProcessedRow[] => {
+ if (rowProcessing === DataTableRowProcessing.None) {
+ return data.map((row, loadedIndex) => ({ row, loadedIndex }));
+ }
+ const term = globalFilter.trim().toLocaleLowerCase();
+ const rows = data
+ .map((row, loadedIndex) => ({ row, loadedIndex }))
+ .filter(({ row }) => {
+ const rowValues = row as Record;
+ const matchesColumns = Object.entries(filters).every(([field, entry]) =>
+ matchesFilterEntry(valueAtPath(rowValues, field), entry),
+ );
+ if (!matchesColumns) return false;
+ if (!term || !globalFilterFields?.length) return true;
+ return globalFilterFields.some((field) =>
+ String(valueAtPath(rowValues, field) ?? '')
+ .toLocaleLowerCase()
+ .includes(term),
+ );
+ });
+
+ if (!sort) return rows;
+ return [...rows].sort((left, right) => {
+ const comparison = compareValues(
+ valueAtPath(left.row as Record, sort.field),
+ valueAtPath(right.row as Record, sort.field),
+ );
+ return sort.direction === 'ascending' ? comparison : -comparison;
+ });
+};
diff --git a/Source/DataTables/useDataTableMessages.ts b/Source/DataTables/useDataTableMessages.ts
new file mode 100644
index 00000000..8eeef242
--- /dev/null
+++ b/Source/DataTables/useDataTableMessages.ts
@@ -0,0 +1,42 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { useCratisComponentsConfig } from '../Common/CratisComponentsProvider';
+import type { DataTableMessageOverrides } from './DataTableMessageOverrides';
+import type { DataTableMessages } from './DataTableMessages';
+
+/**
+ * Resolves each table message from its prop, then the provider's `dataTable` messages, then the default.
+ * @param overrides The messages passed as props.
+ * @returns The messages to show.
+ */
+export const useDataTableMessages = (
+ overrides: DataTableMessageOverrides,
+): DataTableMessages => {
+ const { messages } = useCratisComponentsConfig();
+ const dataTableMessages = messages?.dataTable;
+ return {
+ resolvedSelectionAriaLabel:
+ overrides.selectionAriaLabel ?? dataTableMessages?.selectRow ?? 'Select row',
+ resolvedSelectAllAriaLabel:
+ overrides.selectAllAriaLabel ??
+ dataTableMessages?.selectAllRows ??
+ 'Select all rows',
+ resolvedGlobalSearchPlaceholder:
+ overrides.globalSearchPlaceholder ?? dataTableMessages?.search ?? 'Search…',
+ resolvedGlobalSearchAriaLabel:
+ overrides.globalSearchAriaLabel ??
+ dataTableMessages?.searchAriaLabel ??
+ 'Search table',
+ resolvedLoadingMessage:
+ overrides.loadingMessage ?? dataTableMessages?.loading ?? 'Loading…',
+ resolvedFailureMessage:
+ overrides.failureMessage ??
+ dataTableMessages?.failed ??
+ 'Could not load data.',
+ resolvedUnauthorizedMessage:
+ overrides.unauthorizedMessage ??
+ dataTableMessages?.unauthorized ??
+ 'You are not authorized to view this data.',
+ };
+};
diff --git a/Source/DataTables/useDataTableState.ts b/Source/DataTables/useDataTableState.ts
new file mode 100644
index 00000000..64fff898
--- /dev/null
+++ b/Source/DataTables/useDataTableState.ts
@@ -0,0 +1,53 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type {
+ DataTableFilterConstraint,
+ DataTableFilterMeta,
+} from './DataTableFilterMeta';
+import type { DataTableSort } from './DataTableSort';
+import type { DataTableState } from './DataTableState';
+import type { DataTableStateProps } from './DataTableStateProps';
+import { useControllableState } from './useControllableState';
+
+/**
+ * The table's filter, search and sort state, each controlled by its prop when set and kept by the
+ * table otherwise. Every change is reported through its callback in both modes.
+ * @param props The state props of the table.
+ * @returns The current state and its setters.
+ */
+export const useDataTableState = ({
+ defaultFilters,
+ filters: filtersProp,
+ onFilter,
+ globalFilter: globalFilterProp,
+ onGlobalFilterChange,
+ sort: sortProp,
+ onSortChange,
+}: DataTableStateProps): DataTableState => {
+ const [filters, setFilters] = useControllableState(
+ filtersProp,
+ defaultFilters ?? {},
+ onFilter,
+ );
+ const [globalFilter, setGlobalFilter] = useControllableState(
+ globalFilterProp,
+ '',
+ onGlobalFilterChange,
+ );
+ const [sort, setSort] = useControllableState(
+ sortProp,
+ null,
+ onSortChange,
+ );
+ const updateFilter = (
+ field: string,
+ constraint: DataTableFilterConstraint | undefined,
+ ) => {
+ const next = { ...filters };
+ if (constraint) next[field] = constraint;
+ else delete next[field];
+ setFilters(next);
+ };
+ return { filters, updateFilter, globalFilter, setGlobalFilter, sort, setSort };
+};
diff --git a/Source/DataTables/useRowSelection.ts b/Source/DataTables/useRowSelection.ts
new file mode 100644
index 00000000..38dba6ca
--- /dev/null
+++ b/Source/DataTables/useRowSelection.ts
@@ -0,0 +1,118 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { useEffect, useMemo, useRef } from 'react';
+import type { RowSelection } from './RowSelection';
+import type { RowSelectionSource } from './RowSelectionSource';
+import { valueAtPath } from './valueAtPath';
+
+/**
+ * Single and multiple row selection by data key, or by object identity without one.
+ * @param source The rows and the selection to work with.
+ * @returns The selection state and actions.
+ */
+export const useRowSelection = ({
+ data,
+ visibleRows,
+ dataKey,
+ selection,
+ selectedItems,
+ onSelectedItemsChange,
+}: RowSelectionSource): RowSelection => {
+ const dataKeyIdentity = (row: TData) =>
+ String(valueAtPath(row as Record, dataKey!));
+ const dataKeyCounts = useMemo(() => {
+ const counts = new Map();
+ if (!dataKey) return counts;
+ for (const row of data) {
+ const identity = String(valueAtPath(row as Record, dataKey));
+ counts.set(identity, (counts.get(identity) ?? 0) + 1);
+ }
+ return counts;
+ }, [data, dataKey]);
+ const firstLoadedIndexByDataKey = useMemo(() => {
+ const indices = new Map();
+ if (!dataKey) return indices;
+ data.forEach((row, loadedIndex) => {
+ const identity = String(valueAtPath(row as Record, dataKey));
+ if (!indices.has(identity)) indices.set(identity, loadedIndex);
+ });
+ return indices;
+ }, [data, dataKey]);
+ const selectedLoadedIndex = selection ? data.indexOf(selection) : -1;
+ const selectedDataKey = selection && dataKey ? dataKeyIdentity(selection) : undefined;
+ const isSelectedRow = (row: TData, loadedIndex: number) => {
+ if (!selection) return false;
+ if (!dataKey) return loadedIndex === selectedLoadedIndex;
+
+ const identity = dataKeyIdentity(row);
+ if (identity !== selectedDataKey) return false;
+ if ((dataKeyCounts.get(identity) ?? 0) <= 1) return true;
+ if (selectedLoadedIndex >= 0) return loadedIndex === selectedLoadedIndex;
+ 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 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 {
+ dataKeyIdentity,
+ isSelectedRow,
+ isRowSelected,
+ toggleRowSelection,
+ allFilteredRowsSelected,
+ selectAllRef,
+ toggleSelectAll,
+ };
+};
diff --git a/Source/DataTables/valueAtPath.ts b/Source/DataTables/valueAtPath.ts
new file mode 100644
index 00000000..659d0797
--- /dev/null
+++ b/Source/DataTables/valueAtPath.ts
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/** A value read from a row, with functions turned into their text. */
+export type CellValue =
+ string | number | boolean | bigint | symbol | Date | object | null | undefined;
+
+const asCellValue = (value: unknown): CellValue =>
+ typeof value === 'function' ? String(value) : (value as CellValue);
+
+/**
+ * Reads a dotted property path from a row, only through the row's own properties.
+ * @param row The row.
+ * @param path The dotted path, such as `address.city`.
+ * @returns The value, or undefined when any segment is missing.
+ */
+export const valueAtPath = (
+ row: Record,
+ path: string | undefined,
+): CellValue => {
+ if (!path) return undefined;
+ let current: CellValue = row;
+ for (const segment of path.split('.')) {
+ if (current === null || typeof current !== 'object') return undefined;
+ const record = current as Record;
+ if (!Object.hasOwn(record, segment)) return undefined;
+ current = asCellValue(record[segment]);
+ }
+ return current;
+};
diff --git a/Source/scripts/generate-parts.mjs b/Source/scripts/generate-parts.mjs
index 45b12f22..4bb68918 100644
--- a/Source/scripts/generate-parts.mjs
+++ b/Source/scripts/generate-parts.mjs
@@ -385,7 +385,13 @@ export const partDefinitions = {
'row',
'cell',
],
- sources: ['DataTables/DataTableCore.tsx'],
+ sources: [
+ 'DataTables/DataTableCore.tsx',
+ 'DataTables/DataTableSearch.tsx',
+ 'DataTables/DataTableHeaderCell.tsx',
+ 'DataTables/DataTableBody.tsx',
+ 'DataTables/DataTableRow.tsx',
+ ],
ptKeys: [
'root',
'search',