diff --git a/apps/www/src/content/docs/components/filter-chip/index.mdx b/apps/www/src/content/docs/components/filter-chip/index.mdx index 7558ac6eb..0a1f1fccd 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -62,6 +62,10 @@ FilterChip supports custom filter operations through the `operations` prop. When - When a single operation is provided, it is displayed as static text - If no operations are specified, default operations are used based on the column type +The chip starts on the first operation. Pass `operation` with the active value to +start on another one, for example when you restore a filter from a saved query. +A value the list does not have falls back to the first one. + ### Controlled diff --git a/apps/www/src/content/docs/components/filter-chip/props.ts b/apps/www/src/content/docs/components/filter-chip/props.ts index bbd8d2d6b..482e8aea9 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -26,6 +26,11 @@ export interface FilterChipProps { /** Optional array of operations for the type of filter operation */ operations?: { label: string; value: string }[]; + /** Active operation, by its `value`. A value `operations` does not have falls back to the first one. + * @default the first entry of `operations` + */ + operation?: string; + /** Callback when the filter value changes; receives the value and the active * operation. For `select` and `multiselect`, the value is the picked * option's `value`, with its type kept. For `number`, non-numeric input is diff --git a/packages/react/components/data-table/__tests__/data-table.test.tsx b/packages/react/components/data-table/__tests__/data-table.test.tsx index a006720d9..737a77052 100644 --- a/packages/react/components/data-table/__tests__/data-table.test.tsx +++ b/packages/react/components/data-table/__tests__/data-table.test.tsx @@ -1,6 +1,7 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { getSlot } from '~/test-utils/data-slots'; import { DataTable } from '../data-table'; import styles from '../data-table.module.css'; import { DataTableColumnDef, DataTableQuery } from '../data-table.types'; @@ -669,6 +670,37 @@ describe('DataTable', () => { }); }); + describe('Restored filter chips', () => { + const chipColumns: DataTableColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + filterType: 'string' + } + ]; + + it('shows the operator the query carries', async () => { + const { container } = render( + + + + ); + await act(() => new Promise(r => setTimeout(r, 0))); + + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is not' + ); + }); + }); + describe('Restored date filters', () => { type Dated = { id: string; when?: string }; const dated: Dated[] = [ diff --git a/packages/react/components/data-table/components/filters.tsx b/packages/react/components/data-table/components/filters.tsx index b120f22df..a79dc5637 100644 --- a/packages/react/components/data-table/components/filters.tsx +++ b/packages/react/components/data-table/components/filters.tsx @@ -158,6 +158,7 @@ export function Filters({ ) } columnType={filter.filterType} + operation={filter.operator} options={filter.options} selectProps={filter.selectProps} calendarProps={filter.calendarProps} diff --git a/packages/react/components/data-view/__tests__/data-view.test.tsx b/packages/react/components/data-view/__tests__/data-view.test.tsx index 0592c7d8c..07b7bf551 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -9,7 +9,7 @@ import { it, vi } from 'vitest'; - +import { getSlot } from '~/test-utils/data-slots'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name import { DataView } from '../data-view'; import type { @@ -562,6 +562,31 @@ describe('DataView', () => { }); }); + describe('Restored filter chips', () => { + it('shows the operator the query carries', async () => { + const { container } = render( + + + + + + + ); + await act(() => new Promise(r => setTimeout(r, 0))); + + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is not' + ); + }); + }); + describe('Multi-view', () => { const views: ViewSpec[] = [ { value: 'table', label: 'Table' }, diff --git a/packages/react/components/data-view/components/filters.tsx b/packages/react/components/data-view/components/filters.tsx index 28f6a5cdc..ab36936e0 100644 --- a/packages/react/components/data-view/components/filters.tsx +++ b/packages/react/components/data-view/components/filters.tsx @@ -170,6 +170,7 @@ export function Filters({ ) } columnType={filter.filterType} + operation={filter.operator} options={filter.options} selectProps={filter.selectProps} className={classNames?.filterChips} diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index c82920a60..391d9a2d8 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -47,6 +47,48 @@ describe('FilterChip', () => { }); }); + describe('Operation', () => { + const flushMicrotasks = async () => + await new Promise(r => setTimeout(r, 0)); + + const operationText = async (ui: React.ReactElement) => { + const { container } = render(ui); + await act(flushMicrotasks); + return getSlot(container, 'filter-chip-operation')?.textContent; + }; + + it('shows the operation named by the prop', async () => { + expect( + await operationText() + ).toBe('is not'); + }); + + it('falls back to the first operation when the type does not have it', async () => { + expect( + await operationText() + ).toBe('is'); + }); + + it('shows the first operation when the prop is absent', async () => { + expect(await operationText()).toBe('is'); + }); + + it('reads the operation from a custom list', async () => { + expect( + await operationText( + + ) + ).toBe('is not'); + }); + }); + describe('Remove Functionality', () => { it('shows remove button when onRemove is provided', () => { const onRemove = vi.fn(); diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 4afe7aeff..b3f5a8acb 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { TZDate } from '@date-fns/tz'; import { cva, cx, VariantProps } from 'class-variance-authority'; import { parseISO } from 'date-fns'; @@ -103,6 +104,12 @@ export interface FilterChipProps onOperationChange?: (operation: string) => void; leadingIcon?: ReactNode; operations?: FilterOperator[]; + /** + * Active operation, by its `value`. A value `operations` does not have falls + * back to the first one. + * @default the first entry of `operations` + */ + operation?: string; selectProps?: BaseSelectProps; /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */ calendarProps?: FilterChipCalendarProps; @@ -129,6 +136,7 @@ export const FilterChip = ({ leadingIcon, variant, operations, + operation: operationProp, selectProps, calendarProps, ...props @@ -137,9 +145,18 @@ export const FilterChip = ({ ? operations : filterOperators[columnType]; - const [operation, setOperation] = useState( - computedOperations?.[0] - ); + const selectedOperation = + operationProp === undefined + ? undefined + : (computedOperations?.find(option => option.value === operationProp) ?? + computedOperations?.[0]); + + const [operation, setOperation] = useControlled({ + controlled: selectedOperation, + default: computedOperations?.[0], + name: 'FilterChip', + state: 'operation' + }); const isNumberColumn = columnType === FilterType.number; // `??` not `||`, since a falsy option value like `0` is a real selection. // For `number`, the value is shown without an exponent (`1e-7`), and a @@ -183,7 +200,7 @@ export const FilterChip = ({ setOperation(operation); if (operation?.value) onOperationChange?.(operation.value); }, - [onOperationChange] + [onOperationChange, setOperation] ); const handleFilterValueChange = useCallback(