From 574c293342a0727178247e55c83460c3314a2d6e Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 15 Jul 2026 17:48:22 +0530 Subject: [PATCH] =?UTF-8?q?feat(data-table):=20redesign=20Filters=20?= =?UTF-8?q?=E2=80=94=20menu-driven=20add=20flow=20+=20segmented=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the add-filter popover (nested selects) with a Menu flyout: all filterable fields visible on open; number/date/time fields get a condition step (field ▸ condition ▸ value), others go straight to value. - Segmented filter chips: field │ operator │ value │ ✕, with a searchable operator dropdown on the operator segment. - Add `FilterConfig.chooseOperator` (defaults true for number/date/datetime/time, false otherwise) to opt a field in/out of the condition step. - Friendlier operator labels (is / is not / is between / is any of …), enum multi-select summarized as "N items", compact date ranges. - Extract shared `FilterCheckbox` + `EnumOptionList` for a consistent primary-colored checkbox across all filter surfaces. - `Menu.Content` gains `position.trackAnchor` to pin the menu when opening it shifts the trigger. Co-Authored-By: Claude Opus 4.8 --- .changeset/data-table-filter-redesign.md | 11 + .../core/src/components/data-table/i18n.ts | 14 +- .../components/data-table/toolbar.test.tsx | 330 +++-- .../src/components/data-table/toolbar.tsx | 1190 ++++++++++------- packages/core/src/components/menu.tsx | 9 +- packages/core/src/lib/position.ts | 7 + packages/core/src/types/collection.ts | 32 +- 7 files changed, 954 insertions(+), 639 deletions(-) create mode 100644 .changeset/data-table-filter-redesign.md diff --git a/.changeset/data-table-filter-redesign.md b/.changeset/data-table-filter-redesign.md new file mode 100644 index 00000000..087691c8 --- /dev/null +++ b/.changeset/data-table-filter-redesign.md @@ -0,0 +1,11 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Redesign `DataTable.Filters` for a faster, more direct filtering experience. + +- **Add-filter menu**: replaces the popover + nested selects with a `Menu` flyout. Every filterable field is visible on open; hovering a field reveals a type-specific editor. Fields whose operator matters (number, date/time) get a condition step — **field ▸ condition ▸ value** — while others go straight to the value. +- **Segmented filter chips**: each active filter renders as `field │ operator │ value │ ✕`. The operator segment opens a searchable dropdown to switch the condition; the value segment opens the type editor. +- **New `FilterConfig.chooseOperator?: boolean`**: opt a field in/out of the condition step. Defaults to `true` for `number`/`date`/`datetime`/`time` and `false` otherwise. The chip always lets you change the operator regardless. +- Friendlier operator labels (`is`, `is not`, `is between`, `is any of`, …), multi-select enum values summarized as "N items", compact date ranges, and consistent primary-colored checkboxes across all filter surfaces. +- `Menu.Content` gains a `position.trackAnchor` option to keep a menu pinned in place when opening it shifts the trigger. diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 9732593d..00cca8e1 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -33,10 +33,12 @@ export const dataTableLabels = defineI18nLabels({ addFilter: "Add filter", applyFilter: "Apply", removeFilter: "Remove filter", + filterOperatorSearchPlaceholder: "Search...", + filterValuePlaceholder: (props: { field: string }) => `Enter ${props.field.toLowerCase()}`, filterBooleanTrue: "True", filterBooleanFalse: "False", - filterOperator_eq: "equals", - filterOperator_ne: "not equals", + filterOperator_eq: "is", + filterOperator_ne: "is not", filterOperator_gt: "greater than", filterOperator_gte: "greater than or equal", filterOperator_lt: "less than", @@ -47,9 +49,9 @@ export const dataTableLabels = defineI18nLabels({ filterOperator_hasSuffix: "ends with", filterOperator_notHasPrefix: "does not start with", filterOperator_notHasSuffix: "does not end with", - filterOperator_between: "between", - filterOperator_in: "in", - filterOperator_nin: "not in", + filterOperator_between: "is between", + filterOperator_in: "is any of", + filterOperator_nin: "is none of", // Date-specific operator labels (date filters drop gt/lt/ne and treat the // boundary as inclusive). filterDateOperator_eq: "exact date", @@ -90,6 +92,8 @@ export const dataTableLabels = defineI18nLabels({ addFilter: "フィルタを追加", applyFilter: "適用", removeFilter: "フィルタを削除", + filterOperatorSearchPlaceholder: "検索...", + filterValuePlaceholder: (props: { field: string }) => `${props.field}を入力`, filterBooleanTrue: "真", filterBooleanFalse: "偽", filterOperator_eq: "と等しい", diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index 9ac83509..b9fa1ece 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -60,6 +60,66 @@ function TestFilters({ const wrapper = createAppShellWrapper("en"); +// --------------------------------------------------------------------------- +// Filter chip segment helpers +// --------------------------------------------------------------------------- +// The redesigned filter chip (`data-slot="data-table-filter-chip"`) is a +// segmented control. Its buttons are, in order, [operator?, value, remove]: +// - field → a plain (never a button) +// - operator → a + } + /> + {/* trackAnchor={false}: adding a filter inserts a chip before the trigger, + shifting it — pin the menu where it opened so it doesn't jump while the + user adds several filters in a row. It stays open until an outside click. */} + + {columns.map((col) => ( + + ))} + + + ); +} +AddFilterMenu.displayName = "DataTable.AddFilterMenu"; + +/** + * A single field row in the add-filter menu; opens its editor as a submenu. + * + * When the field opts into a condition step (`shouldChooseOperator`), the + * submenu lists the operators — each opening a further submenu with the value + * editor (field ▸ condition ▸ value). Otherwise the submenu shows the value + * editor directly with a default operator (field ▸ value). + */ +function FieldSubmenu({ + column, + control, +}: { + column: FilterableColumn; + control: CollectionControl; +}) { + const config = column.filter; + const field = config.field; + const label = column.label ?? field; + const active = control.filters.find((f) => f.field === field); + + // Own the submenu's open state so a single-shot editor (boolean/date/text) can + // close just this submenu on commit and return to the field list, while the + // parent add-filter menu stays open for adding more filters. + const [subOpen, setSubOpen] = useState(false); + + const operators = getAddFilterOperators(config.type); + const showConditionLevel = shouldChooseOperator(config) && operators.length > 1; + + return ( + + + {label} + + + + {showConditionLevel ? ( + operators.map((operator) => ( + + )) + ) : ( + setSubOpen(false)} + /> + )} + + + ); +} + +/** + * Second level of the add-filter menu (condition ▸ value): one operator that + * opens a further submenu with the value editor for that operator. Prefills the + * editor from the active filter when the operator matches. + */ +function OperatorSubmenu({ + column, + operator, + activeFilter, + control, +}: { + column: FilterableColumn; + operator: FilterOperator; + activeFilter: Filter | undefined; + control: CollectionControl; +}) { + const t = useDataTableT(); + const config = column.filter; + const [subOpen, setSubOpen] = useState(false); + + // Reuse the chip's value editor with the operator fixed (hidden) — it gives a + // value input + Apply button per operator, including the "between" range. + const editorFilter: Filter = + activeFilter && activeFilter.operator === operator + ? activeFilter + : { field: config.field, operator, value: undefined }; + + return ( + + + {getOperatorLabel(operator, t, config.type)} + + + + setSubOpen(false)} + hideOperator + /> + + + ); +} + +/** Dispatches to the right quick editor for a field's type. */ +function FieldSubmenuEditor({ + column, + filter, + control, + onCommitted, +}: { + column: FilterableColumn; + filter: Filter | undefined; + control: CollectionControl; + /** Close this submenu (back to the field list) after a single-shot commit. */ + onCommitted: () => void; +}) { + const t = useDataTableT(); + const config = column.filter; + const field = config.field; + const label = column.label ?? field; + + // Enum: live multi-select checkbox list (commit on every toggle, menu stays open). + if (config.type === "enum") { + const selected = Array.isArray(filter?.value) ? (filter.value as string[]) : []; + const toggle = (value: string) => { + const set = new Set(selected); + if (set.has(value)) set.delete(value); + else set.add(value); + const next = [...set]; + if (next.length === 0) control.removeFilter(field); + else control.addFilter(field, "in", next); + }; + return ; + } + + // Boolean: True / False items. closeOnClick={false} keeps the add-filter menu + // open; we close just this submenu via onCommitted. + if (config.type === "boolean") { + return ( + <> + {[true, false].map((v) => ( + { + control.addFilter(field, "eq", v); + onCommitted(); + }} + > + {v ? t("filterBooleanTrue") : t("filterBooleanFalse")} + + ))} + + ); + } + + // Date: calendar picker (commit on pick, then close this submenu). + if (config.type === "date") { + const current = typeof filter?.value === "string" ? filter.value : ""; + return ( +
+ { + if (v) { + control.addFilter(field, "eq", v); + onCommitted(); + } + }} + /> +
+ ); + } + + // String / number / uuid (and datetime/time): focused input, commit on Enter. + const op = DEFAULT_OPERATOR[config.type]; + const initial = typeof filter?.value === "string" ? filter.value : ""; + const inputType = config.type === "number" ? "number" : "text"; + return ( + { + if (raw.trim() === "") { + control.removeFilter(field); + onCommitted(); + return; + } + if (!isAddFilterDraftValueValid(config.type, op, raw)) return; + control.addFilter( + field, + op, + toAddFilterSubmittedValue(config.type, op, raw), + config.type === "string" ? { caseSensitive: false } : undefined, + ); + onCommitted(); + }} + /> + ); +} + +/** + * Text/number input rendered inside a Menu submenu. Base UI's menu applies + * typeahead and roving focus at the popup level, so we stop keydown propagation + * to keep typing in the input, and focus it once the submenu opens. + */ +function SubmenuFilterInput({ + inputType, + defaultValue, + ariaLabel, + placeholder, + onCommit, +}: { + inputType: "text" | "number"; + defaultValue: string; + ariaLabel: string; + placeholder?: string; + onCommit: (value: string) => void; +}) { + const ref = useRef(null); + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + const id = requestAnimationFrame(() => ref.current?.focus()); + return () => cancelAnimationFrame(id); + }, []); + + return ( +
+ setValue(e.target.value)} + onKeyDown={(e) => { + // Keep keystrokes in the input rather than the menu's typeahead/nav. + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + onCommit(value); + } + }} + className="astw:h-8 astw:text-sm" + /> +
+ ); +} + // ============================================================================= // BetweenInputGroup — shared UI for "between" filter inputs // ============================================================================= @@ -181,6 +491,7 @@ function BetweenInputGroup({ value={values[0]} onChange={(e) => onChangeMin(e.target.value)} onKeyDown={(e) => { + e.stopPropagation(); if (e.key === "Enter") onSubmit(); }} className="astw:h-full astw:text-sm astw:border-0 astw:shadow-none astw:focus-visible:ring-0" @@ -196,6 +507,7 @@ function BetweenInputGroup({ value={values[1]} onChange={(e) => onChangeMax(e.target.value)} onKeyDown={(e) => { + e.stopPropagation(); if (e.key === "Enter") onSubmit(); }} className="astw:h-full astw:text-sm astw:border-0 astw:shadow-none astw:focus-visible:ring-0" @@ -229,343 +541,6 @@ function DateFilterPicker({ ); } -function AddFilterPopover({ - availableColumns, - control, -}: { - availableColumns: FilterableColumn[]; - control: CollectionControl; -}) { - const t = useDataTableT(); - const [open, setOpen] = useState(false); - const [field, setField] = useState(null); - const [operator, setOperator] = useState("eq"); - const [value, setValue] = useState(""); - const [caseSensitive, setCaseSensitive] = useState(false); - - const fieldLabelMap = useMemo( - () => new Map(availableColumns.map((col) => [col.filter.field, col.label ?? col.filter.field])), - [availableColumns], - ); - - const selectedColumn = useMemo( - () => availableColumns.find((col) => col.filter.field === field) ?? availableColumns[0] ?? null, - [availableColumns, field], - ); - - const operatorItems = useMemo( - () => - selectedColumn ? getAddFilterOperators(selectedColumn.filter.type) : ([] as FilterOperator[]), - [selectedColumn], - ); - - const canSubmit = - selectedColumn != null && - isAddFilterDraftValueValid(selectedColumn.filter.type, operator, value); - - const initDraft = useCallback((column: FilterableColumn | null) => { - if (!column) { - setField(null); - setOperator("eq"); - setValue(""); - setCaseSensitive(false); - return; - } - - setField(column.filter.field); - setOperator(DEFAULT_OPERATOR[column.filter.type]); - setValue(getInitialAddFilterDraftValue(column.filter.type)); - setCaseSensitive(false); - }, []); - - const handleOpenChange = useCallback( - (isOpen: boolean) => { - setOpen(isOpen); - - if (isOpen) { - initDraft(availableColumns[0] ?? null); - } - }, - [availableColumns, initDraft], - ); - - const handleFieldChange = useCallback( - (nextField: string | null) => { - if (!nextField) return; - - const nextColumn = availableColumns.find((col) => col.filter.field === nextField) ?? null; - if (!nextColumn) return; - - setField(nextField); - setOperator(DEFAULT_OPERATOR[nextColumn.filter.type]); - setValue(getInitialAddFilterDraftValue(nextColumn.filter.type)); - setCaseSensitive(false); - }, - [availableColumns], - ); - - const handleSubmit = useCallback(() => { - if (!selectedColumn) return; - if (!isAddFilterDraftValueValid(selectedColumn.filter.type, operator, value)) return; - - control.addFilter( - selectedColumn.filter.field, - operator, - toAddFilterSubmittedValue(selectedColumn.filter.type, operator, value), - selectedColumn.filter.type === "string" ? { caseSensitive } : undefined, - ); - setOpen(false); - }, [selectedColumn, value, operator, caseSensitive, control]); - - const renderValueEditor = () => { - if (!selectedColumn) return null; - - const config = selectedColumn.filter; - - if (config.type === "enum") { - const selectedValues = Array.isArray(value) ? (value as string[]) : []; - - return ( -
- {config.options.map((option) => { - const isChecked = selectedValues.includes(option.value); - return ( - - ); - })} -
- ); - } - - if (config.type === "boolean") { - return ( - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - className="astw:h-8 astw:text-sm" - /> - ); - } - - if (config.type === "number") { - if (operator === "between") { - const [min, max] = Array.isArray(value) ? value : ["", ""]; - return ( - setValue([v, max])} - onChangeMax={(v) => setValue([min, v])} - onSubmit={handleSubmit} - inputProps={{ type: "number" }} - /> - ); - } - return ( - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - className="astw:h-8 astw:text-sm" - /> - ); - } - - return ( - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - className="astw:h-8 astw:text-sm" - /> - ); - }; - - return ( - - - - {t("addFilter")} - - } - /> - {/* Stacking context on the portal container so the popup renders above - positioned elements like DataTable's sticky header row (z-index: 10) — - the Popup's own z-index is inert (it's position: static; the - Positioner is the positioned element). Same pattern as Menu/Tooltip. */} - - - -
- { - if (!nextOp) return; - const wasBetween = operator === "between"; - const isBetween = nextOp === "between"; - setOperator(nextOp); - if (wasBetween !== isBetween) { - setValue(isBetween ? ["", ""] : ""); - } - }} - mapItem={(op) => ({ - value: op, - label: getOperatorLabel(op, t, selectedColumn?.filter.type), - })} - className="astw:h-8 astw:text-sm" - /> - ) : null} - {renderValueEditor()} - {selectedColumn?.filter.type === "string" && ( - - )} - -
-
-
-
-
- ); -} - // ============================================================================= // FilterChip — per-filter popover-based editor // ============================================================================= @@ -584,39 +559,118 @@ function FilterChip({ const config = column.filter; const label = column.label ?? config.field; - const [open, setOpen] = useState(false); - - const handleOpenChange = useCallback((isOpen: boolean) => { - setOpen(isOpen); - }, []); - - const handleClose = useCallback(() => { - setOpen(false); - }, []); + const [opOpen, setOpOpen] = useState(false); + const [valOpen, setValOpen] = useState(false); const handleRemove = useCallback(() => { control.removeFilter(config.field); }, [control, config.field]); - const chipLabel = getChipDisplayLabel(label, filter, config, t, locale); + // Switching operator re-commits the filter, seeding a valid value when the + // arity changes (single ↔ between) so the intermediate filter is never + // malformed. Fine-tuning the value happens in the value segment. + const handleOperatorSelect = useCallback( + (nextOp: FilterOperator) => { + const arity = (op: FilterOperator) => (op === "between" ? 2 : 1); + if (arity(nextOp) === arity(filter.operator)) { + control.addFilter( + config.field, + nextOp, + filter.value, + config.type === "string" && filter.caseSensitive ? { caseSensitive: true } : undefined, + ); + } else if (nextOp === "between") { + const v = filter.value; + if (config.type === "number") { + const n = typeof v === "number" ? v : Number(v); + control.addFilter(config.field, nextOp, { min: n, max: n }); + } else { + const s = v == null ? "" : String(v); + control.addFilter(config.field, nextOp, { min: s, max: s }); + } + } else { + const range = (filter.value ?? {}) as { min?: unknown; max?: unknown }; + const lower = range.min ?? range.max ?? ""; + control.addFilter( + config.field, + nextOp, + config.type === "number" ? Number(lower) : String(lower), + ); + } + setOpOpen(false); + }, + [control, config.field, config.type, filter.operator, filter.value, filter.caseSensitive], + ); + + const operators = getAddFilterOperators(config.type); + const operatorLabel = getOperatorLabel(filter.operator, t, config.type); + const valueLabel = formatFilterValue(filter, config, t, locale, label); + + const segment = + "astw:flex astw:items-center astw:h-6 astw:px-2 astw:text-xs astw:whitespace-nowrap astw:outline-hidden"; + const interactiveSegment = cn( + segment, + "astw:cursor-pointer astw:hover:bg-accent astw:focus-visible:bg-accent astw:data-popup-open:bg-accent", + ); return ( -
- +
+ {/* Field segment (icon arrives in Stage 2) */} + {label} + + {/* Operator segment — searchable dropdown when more than one operator */} + {operators.length > 1 ? ( + + + {operatorLabel} + + } + /> + + + + + + + + + ) : ( + {operatorLabel} + )} + + {/* Value segment — opens the value editor (operator hidden; it lives above) */} + - {chipLabel} + {valueLabel || } - + } /> - {/* See AddFilterPopover — stacking context so the popup clears the - sticky table header. */} + {/* See AddFilterMenu — stacking context so the popup clears the sticky header. */} setValOpen(false)} + hideOperator /> - + +
+ ); +} + +// ============================================================================= +// OperatorList — searchable operator picker shown in the chip's operator segment +// ============================================================================= + +function OperatorList({ + operators, + current, + type, + onSelect, +}: { + operators: FilterOperator[]; + current: FilterOperator; + type: FilterConfig["type"]; + onSelect: (op: FilterOperator) => void; +}) { + const t = useDataTableT(); + const [query, setQuery] = useState(""); + const ref = useRef(null); + + useEffect(() => { + const id = requestAnimationFrame(() => ref.current?.focus()); + return () => cancelAnimationFrame(id); + }, []); + + const q = query.trim().toLowerCase(); + const items = operators + .map((op) => ({ op, label: getOperatorLabel(op, t, type) })) + .filter(({ label }) => label.toLowerCase().includes(q)); + + return ( +
+
+ + setQuery(e.target.value)} + placeholder={t("filterOperatorSearchPlaceholder")} + className="astw:h-9 astw:w-full astw:bg-transparent astw:text-sm astw:outline-hidden astw:placeholder:text-muted-foreground" + /> +
+
+ {items.map(({ op, label }) => ( + + ))} + {items.length === 0 && ( +
+ )} +
); } @@ -658,11 +782,18 @@ function FilterPopoverContent({ filter, control, onClose, + hideOperator = false, }: { column: Column> & { filter: FilterConfig }; filter: Filter; control: CollectionControl; onClose: () => void; + /** + * Hide the in-editor operator selector. Used by the segmented FilterChip, + * where the operator lives in its own chip segment; the editor commits with + * the filter's current operator. + */ + hideOperator?: boolean; }) { const config = column.filter; const label = column.label ?? config.field; @@ -672,11 +803,23 @@ function FilterPopoverContent({ return ; case "boolean": return ( - + ); case "string": return ( - + ); case "uuid": return ( @@ -684,7 +827,13 @@ function FilterPopoverContent({ ); case "number": return ( - + ); case "datetime": case "date": @@ -696,11 +845,80 @@ function FilterPopoverContent({ filter={filter} control={control} onClose={onClose} + hideOperator={hideOperator} /> ); } } +// ============================================================================= +// Shared filter checkbox controls — one blue (primary) checkbox style reused +// everywhere in the filter UI (enum lists in both the add menu and the chip +// value editor, plus the case-sensitive toggle) so they stay consistent. +// ============================================================================= + +/** The single checkbox visual used across all filter surfaces. */ +function FilterCheckbox({ + checked, + onCheckedChange, + className, +}: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + className?: string; +}) { + return ( + + + + + + ); +} + +/** + * Multi-select option list for enum filters. Rendered identically in the + * add-filter submenu and the chip's value editor so the checkbox style is + * consistent in both places. + */ +function EnumOptionList({ + options, + selected, + onToggle, +}: { + options: readonly SelectOption[]; + selected: string[]; + onToggle: (value: string) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + // ============================================================================= // Enum filter — checkbox list (matching the screenshot) // ============================================================================= @@ -738,34 +956,7 @@ function EnumFilterEditor({ ); return ( -
- {config.options.map((option) => { - const isChecked = selectedValues.includes(option.value); - return ( - - ); - })} -
+ ); } @@ -781,11 +972,13 @@ function BooleanFilterEditor({ filter, control, onClose, + hideOperator = false, }: { config: Extract; filter: Filter; control: CollectionControl; onClose: () => void; + hideOperator?: boolean; }) { const t = useDataTableT(); const [localOp, setLocalOp] = useState( @@ -807,15 +1000,17 @@ function BooleanFilterEditor({ data-slot="data-table-filter-boolean" className="astw:flex astw:flex-col astw:gap-2 astw:p-2" > - { + if (v) setLocalOp(v as BooleanOperator); + }} + mapItem={(op) => ({ value: op, label: getOperatorLabel(op, t) })} + className="astw:h-8 astw:text-sm" + /> + )} { - if (v) setLocalOp(v); - }} - mapItem={(op) => ({ value: op, label: t(`filterOperator_${op}`) })} - className="astw:h-8 astw:text-sm" - /> + {!hideOperator && ( + setLocalValue(e.target.value)} onKeyDown={(e) => { + e.stopPropagation(); if (e.key === "Enter") handleCommit(); }} className="astw:h-8 astw:text-sm" />