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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Source/Common/CratisComponentsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
37 changes: 36 additions & 1 deletion Source/DataPage/DataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -223,6 +226,8 @@ interface IDataPageContext extends DataPageProps<
> {
selectedItem: object | null | undefined;
onSelectionChanged: (event: DataTableSelectionChangeEvent<object>) => void;
selectedItems: object[];
onSelectedItemsChange: (items: object[]) => void;
}

const DataPageContext = React.createContext<IDataPageContext | null>(null);
Expand Down Expand Up @@ -296,6 +301,24 @@ export interface DataPageProps<
*/
onSelectionChange?(event: DataTableSelectionChangeEvent<TDataType>): 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
*/
Expand Down Expand Up @@ -511,13 +534,25 @@ const DataPage = <
props.onSelectionChange?.(event);
};

const [internalSelectedItems, setInternalSelectedItems] = React.useState<TDataType[]>(
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.
const context = {
...props,
selectedItem,
onSelectionChanged: selectionChanged,
selectedItems,
onSelectedItemsChange: selectedItemsChanged,
} as unknown as IDataPageContext;

return (
Expand Down
15 changes: 13 additions & 2 deletions Source/DataTables/Column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ColumnFilterElement,
ColumnFilterMenuLabels,
ColumnFilterMenuParts,
ColumnFilterOption,
} from './ColumnFilterMenu';

/**
Expand Down Expand Up @@ -35,12 +36,22 @@ export interface ColumnProps<TData = unknown> {
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<ColumnFilterMenuLabels>;
/** 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. */
Expand Down
8 changes: 8 additions & 0 deletions Source/DataTables/ColumnFilterMenu.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions Source/DataTables/ColumnFilterMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
55 changes: 49 additions & 6 deletions Source/DataTables/ColumnFilterMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<ColumnFilterMenuLabels>;
/** Stable part attributes. */
Expand All @@ -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':
Expand Down Expand Up @@ -191,6 +208,7 @@ export const ColumnFilterMenu = ({
placeholder,
showMatchModes = true,
filterElement,
filterOptions,
labels,
pt,
constraint,
Expand Down Expand Up @@ -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)),
Expand All @@ -298,7 +318,22 @@ export const ColumnFilterMenu = ({
event?.preventDefault();
};

const valueInput = filterElement
const valueInput = filterOptions
? (
<Dropdown
value={draftValue}
options={filterOptions}
optionLabel='label'
optionValue='value'
placeholder={placeholder}
showClear
onChange={(value) => setDraftValue(() => value)}
aria-label={resolvedLabels.valueAriaLabel(field)}
className={pt?.input?.className}
style={pt?.input?.style}
/>
)
: filterElement
? filterElement({
field,
value: draftValue,
Expand Down Expand Up @@ -390,7 +425,15 @@ export const ColumnFilterMenu = ({
data-open={isOpen || undefined}
data-cratis-part='filter-trigger'
>
<span aria-hidden='true'>⌕</span>
<svg
className='cratis-filter-trigger__icon'
viewBox='0 0 16 16'
fill='currentColor'
aria-hidden='true'
focusable='false'
>
<path d='M1.5 2.5A.5.5 0 0 1 2 2h12a.5.5 0 0 1 .38.82L10 8.1v4.52a.5.5 0 0 1-.24.43l-3 1.8A.5.5 0 0 1 6 14.42V8.1L1.62 2.82a.5.5 0 0 1-.12-.32Z' />
</svg>
</AriaButton>
<Popover
{...pt?.popover}
Expand Down
33 changes: 33 additions & 0 deletions Source/DataTables/DataTableCore.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,39 @@ export const LoadedArray: Story = {
render: () => <LocalTable />,
};

const MultipleSelectionTable = () => {
const [selected, setSelected] = useState<Person[]>([people[1]]);
return (
<DataTableCore<Person>
data={people}
dataKey='id'
emptyMessage='No people'
selectionMode='multiple'
selectedItems={selected}
onSelectedItemsChange={setSelected}
globalFilterFields={['name', 'role']}
>
<Column<Person> selectionMode='multiple' />
<Column<Person> field='name' header='Name' sortable />
<Column<Person>
field='role'
header='Role'
filter
filterOptions={[
{ label: 'Administrator', value: 'Administrator' },
{ label: 'Developer', value: 'Developer' },
{ label: 'Viewer', value: 'Viewer' },
]}
/>
</DataTableCore>
);
};

/** Bulk selection: a checkbox per row, and a select-all that covers the rows the filter leaves visible. */
export const MultipleSelection: Story = {
render: () => <MultipleSelectionTable />,
};

export const Empty: Story = {
render: () => (
<DataTableCore<Person> data={[]} emptyMessage='No people'>
Expand Down
Loading
Loading