From 88991ae422370c5da13afe329138552ed36897e5 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 15 Jul 2026 17:48:22 +0530 Subject: [PATCH 01/23] =?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" />
+ {/* A/B toggle for the two add-filter variants (demo only). */} +
+ Add-filter UI: + {(["menu", "panel"] as const).map((v) => ( + + ))} +
- + diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 8a5bc201..29794049 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -129,7 +129,18 @@ type FilterableColumn = Column> & { type AddFilterDraftValue = string | string[]; /** Use `DataTable.Filters` instead of calling this directly. */ -function DataTableFilters({ className }: { className?: string }) { +function DataTableFilters({ + className, + addFilterVariant = "menu", +}: { + className?: string; + /** + * EXPERIMENTAL — the add-filter surface style, for A/B testing. + * - `"menu"` (default): nested-submenu flyout (`AddFilterMenu`). + * - `"panel"`: single 3-column popover (field ▸ condition ▸ value). + */ + addFilterVariant?: "menu" | "panel"; +}) { const ctx = useDataTableContext(); const control = useCollectionControlOptional(); if (!control) { @@ -158,9 +169,12 @@ function DataTableFilters({ className }: { className?: string }) { return ; })} - {/* Add / edit filter menu: every filterable field is one hover away, with - a type-specific editor in a nested submenu. Active fields are marked. */} - + {/* Add-filter surface: the nested-submenu menu (default) or the 3-column panel. */} + {addFilterVariant === "panel" ? ( + + ) : ( + + )} ); } @@ -206,6 +220,302 @@ function AddFilterMenu({ } AddFilterMenu.displayName = "DataTable.AddFilterMenu"; +// ============================================================================= +// AddFilterPanel — EXPERIMENTAL variant B: one popover, three columns +// (field ▸ condition ▸ value) instead of nested submenus. Live-commit: enum +// toggles, text/number commit on Enter, date on pick. Reuses the same value +// editors and helpers as the menu variant. +// ============================================================================= + +const PANEL_COLUMN_ROW = cn( + "astw:flex astw:w-full astw:items-center astw:gap-2 astw:rounded-sm astw:px-2 astw:py-1.5", + "astw:text-left astw:text-sm astw:outline-hidden astw:cursor-default", + "astw:hover:bg-accent astw:hover:text-accent-foreground astw:focus-visible:bg-accent", +); + +function AddFilterPanel({ + columns, + control, +}: { + columns: FilterableColumn[]; + control: CollectionControl; +}) { + const t = useDataTableT(); + const [open, setOpen] = useState(false); + const [fieldName, setFieldName] = useState(columns[0]?.filter.field ?? ""); + + const selectedColumn = columns.find((c) => c.filter.field === fieldName) ?? columns[0]; + const config = selectedColumn?.filter; + const showConditions = config ? shouldChooseOperator(config) : false; + const operators = config ? getAddFilterOperators(config.type) : []; + + const [operator, setOperator] = useState( + config ? DEFAULT_OPERATOR[config.type] : "eq", + ); + + const selectField = (name: string) => { + setFieldName(name); + const col = columns.find((c) => c.filter.field === name); + if (col) setOperator(DEFAULT_OPERATOR[col.filter.type]); + }; + + const activeFields = new Set(control.filters.map((f) => f.field)); + const activeFilter = control.filters.find((f) => f.field === fieldName); + let effectiveOperator: FilterOperator | undefined; + if (config) { + effectiveOperator = showConditions ? operator : DEFAULT_OPERATOR[config.type]; + } + + return ( + + + + {t("addFilter")} + + } + /> + + + + {/* Column 1 — fields */} +
+ {columns.map((col) => { + const isSelected = col.filter.field === fieldName; + return ( + + ); + })} +
+ + {/* Column 2 — conditions (only when the field opts into choosing one) */} + {showConditions && config && ( +
+ {operators.map((op) => ( + + ))} +
+ )} + + {/* Column 3 — value editor for the chosen field + condition */} +
+ {selectedColumn && effectiveOperator && ( + + )} +
+
+
+
+
+ ); +} +AddFilterPanel.displayName = "DataTable.AddFilterPanel"; + +/** Live-commit value editor for the panel's third column, keyed by field + operator. */ +function PanelValueEditor({ + column, + operator, + filter, + control, +}: { + column: FilterableColumn; + operator: FilterOperator; + filter: Filter | undefined; + control: CollectionControl; +}) { + const t = useDataTableT(); + const config = column.filter; + const field = config.field; + const label = column.label ?? field; + const type = config.type; + + // Enum — live multi-select. + if (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 (live). + if (type === "boolean") { + return ( +
+ {[true, false].map((v) => ( + + ))} +
+ ); + } + + // Between — commit once both bounds are valid. + if (operator === "between") { + return ; + } + + // Date single — commit on pick. + if (type === "date") { + const current = typeof filter?.value === "string" ? filter.value : ""; + return ( +
+ { + if (v) control.addFilter(field, operator, v); + }} + /> +
+ ); + } + + // String / number / uuid / datetime / time — commit on Enter. + const inputType = type === "number" ? "number" : "text"; + const initial = typeof filter?.value === "string" ? filter.value : ""; + return ( +
+ { + if (raw.trim() === "") { + control.removeFilter(field); + return; + } + if (!isAddFilterDraftValueValid(type, operator, raw)) return; + control.addFilter( + field, + operator, + toAddFilterSubmittedValue(type, operator, raw), + type === "string" ? { caseSensitive: false } : undefined, + ); + }} + /> +
+ ); +} + +/** Between-range editor for the panel — commits when both bounds are valid. */ +function PanelBetweenEditor({ + type, + label, + field, + control, +}: { + type: FilterConfig["type"]; + label: string; + field: string; + control: CollectionControl; +}) { + const t = useDataTableT(); + const [min, setMin] = useState(""); + const [max, setMax] = useState(""); + + const tryCommit = (mn: string, mx: string) => { + if (mn.trim() === "" || mx.trim() === "") return; + const draft: AddFilterDraftValue = [mn, mx]; + if (!isAddFilterDraftValueValid(type, "between", draft)) return; + control.addFilter(field, "between", toAddFilterSubmittedValue(type, "between", draft)); + }; + + if (type === "date") { + return ( +
+ { + setMin(v); + tryCommit(v, max); + }} + /> + { + setMax(v); + tryCommit(min, v); + }} + /> +
+ ); + } + + const numeric = type === "number"; + return ( +
+ tryCommit(min, max)} + inputProps={ + numeric ? { type: "number" } : getTemporalInputProps(type as "datetime" | "time") + } + /> +
+ ); +} + /** * A single field row in the add-filter menu; opens its editor as a submenu. * From 83767dea3f3b8bafa7876b8ddc28f62e8a6b55de Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 15 Jul 2026 19:03:47 +0530 Subject: [PATCH 03/23] =?UTF-8?q?refactor(data-table):=20panel=20variant?= =?UTF-8?q?=20=E2=80=94=20Apply=20button,=20fixed=20height,=20pinned,=203?= =?UTF-8?q?=20cols=20for=20all=20multi-op=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback from testing variant B: - Add an explicit Apply button (draft state per type; panel stays open). - Fixed popup height so switching field/condition never resizes/jumps. - disableAnchorTracking so the panel stays put when adding a chip shifts the trigger. - Show the condition column for every field with >1 operator (e.g. string), not just number/date. Co-Authored-By: Claude Opus 4.8 --- .../src/components/data-table/toolbar.tsx | 258 +++++++++--------- 1 file changed, 134 insertions(+), 124 deletions(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 29794049..f4312f47 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -246,8 +246,11 @@ function AddFilterPanel({ const selectedColumn = columns.find((c) => c.filter.field === fieldName) ?? columns[0]; const config = selectedColumn?.filter; - const showConditions = config ? shouldChooseOperator(config) : false; const operators = config ? getAddFilterOperators(config.type) : []; + // In the panel there's always room, so show the condition column for any field + // that has more than one operator (the `chooseOperator` flag only gates the + // menu variant). + const showConditions = operators.length > 1; const [operator, setOperator] = useState( config ? DEFAULT_OPERATOR[config.type] : "eq", @@ -277,16 +280,19 @@ function AddFilterPanel({ } /> - + {/* disableAnchorTracking pins the panel where it opened so adding a chip + (which shifts the trigger) doesn't make it jump — same as the menu. */} + {/* Column 1 — fields */} -
+
{columns.map((col) => { const isSelected = col.filter.field === fieldName; return ( @@ -310,7 +316,7 @@ function AddFilterPanel({ {/* Column 2 — conditions (only when the field opts into choosing one) */} {showConditions && config && ( -
+
{operators.map((op) => ( ))}
); - } - - // Between — commit once both bounds are valid. - if (operator === "between") { - return ; - } - - // Date single — commit on pick. - if (type === "date") { - const current = typeof filter?.value === "string" ? filter.value : ""; - return ( -
- { - if (v) control.addFilter(field, operator, v); - }} - /> -
- ); - } - - // String / number / uuid / datetime / time — commit on Enter. - const inputType = type === "number" ? "number" : "text"; - const initial = typeof filter?.value === "string" ? filter.value : ""; - return ( -
- { - if (raw.trim() === "") { - control.removeFilter(field); - return; - } - if (!isAddFilterDraftValueValid(type, operator, raw)) return; - control.addFilter( - field, - operator, - toAddFilterSubmittedValue(type, operator, raw), - type === "string" ? { caseSensitive: false } : undefined, - ); - }} - /> -
- ); -} - -/** Between-range editor for the panel — commits when both bounds are valid. */ -function PanelBetweenEditor({ - type, - label, - field, - control, -}: { - type: FilterConfig["type"]; - label: string; - field: string; - control: CollectionControl; -}) { - const t = useDataTableT(); - const [min, setMin] = useState(""); - const [max, setMax] = useState(""); - - const tryCommit = (mn: string, mx: string) => { - if (mn.trim() === "" || mx.trim() === "") return; - const draft: AddFilterDraftValue = [mn, mx]; - if (!isAddFilterDraftValueValid(type, "between", draft)) return; - control.addFilter(field, "between", toAddFilterSubmittedValue(type, "between", draft)); - }; - - if (type === "date") { - return ( + } else if (isBetween && type === "date") { + editor = (
{ - setMin(v); - tryCommit(v, max); - }} + onChange={setMin} /> { - setMax(v); - tryCommit(min, v); + onChange={setMax} + /> +
+ ); + } else if (isBetween) { + const numeric = type === "number"; + editor = ( +
+ +
+ ); + } else if (type === "date") { + editor = ( +
+ +
+ ); + } else { + editor = ( +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") apply(); }} + className="astw:h-8 astw:text-sm" />
); } - const numeric = type === "number"; return ( -
- tryCommit(min, max)} - inputProps={ - numeric ? { type: "number" } : getTemporalInputProps(type as "datetime" | "time") - } - /> +
+
{editor}
+
+ +
); } From 0a7c35eb8000899dc9d8624f17f65cd6f596d363 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 15 Jul 2026 19:46:20 +0530 Subject: [PATCH 04/23] fix(data-table): panel date editor uses segmented DateField (no popover jump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover DatePicker (and an inline Calendar) opened/re-rendered inside the panel popover, causing it to jump and even dismiss on interaction. Use the typed segmented DateField instead — no nested popover, stable panel. Reverts the panel height back to the compact fixed size. Co-Authored-By: Claude Opus 4.8 --- .../src/components/data-table/toolbar.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index f4312f47..08f26678 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Menu } from "@/components/menu"; import { Select } from "@/components/select-standalone"; -import { DatePicker } from "@/components/date-field"; +import { DatePicker, DateField } from "@/components/date-field"; import { parseDate, DateFormatter } from "@internationalized/date"; import { useResolvedLocale } from "@/contexts/appshell-context"; import { useDataTableContext } from "./data-table-context"; @@ -356,6 +356,33 @@ function AddFilterPanel({ } AddFilterPanel.displayName = "DataTable.AddFilterPanel"; +/** + * Segmented date input for the panel, bridging the filter's ISO string value + * and the `CalendarDate` the field works with. Uses the typed `DateField` (not + * the popover DatePicker or an inline calendar) — both open/re-render inside the + * panel popover and cause it to jump or dismiss. + */ +function PanelDateInput({ + ariaLabel, + value, + onChange, +}: { + ariaLabel: string; + value: string; + onChange: (value: string) => void; +}) { + const calValue = /^\d{4}-\d{2}-\d{2}$/.test(value) ? parseDate(value) : null; + return ( + onChange(v ? v.toString() : "")} + className="astw:w-full" + /> + ); +} + /** * Draft value editor for the panel's third column, keyed by field + operator. * Holds local draft state and commits via an explicit Apply button (the panel @@ -457,14 +484,16 @@ function PanelValueEditor({
); } else if (isBetween && type === "date") { + // Inline calendars (not the popover DatePicker) — a popover inside the panel + // popover conflicts with dismissal and causes a jump. editor = ( -
- + - - +
); } else { From 93fa43b97762d3c6648deb073ae3f9bf251f414f Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 20 Jul 2026 12:17:16 +0530 Subject: [PATCH 05/23] style(data-table): match filter chip surface to the outline Add-filter button The chip used border-input + flat bg-background, so in dark mode it read flatter than the outline "Add filter" button. Align its border/background tokens with the outline Button (border-border / dark:border-input, dark:bg-input/30) so the pill matches the add-filter container in both themes. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/components/data-table/toolbar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 08f26678..e7e05ec9 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -965,7 +965,8 @@ function FilterChip({ return (
{/* Field segment (icon arrives in Stage 2) */} {label} From c8c252a82fa0185210f276d40e46c7852cbf0b57 Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 20 Jul 2026 18:06:59 +0530 Subject: [PATCH 06/23] refactor(data-table): ship panel filter as the only variant; remove variant A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the two-variant filter experiment into the single panel design (option B) and drop the nested-menu variant A code. - Remove variant-A pieces from toolbar.tsx (AddFilterMenu, field/operator submenus, submenu editors) — DataTableFilters now always renders the 3-column add-filter panel (field ▸ condition ▸ value, Apply) + segmented chips - Revert incidental shared-component changes made for variant A: Menu.trackAnchor, PositionProps.trackAnchor, and FilterConfig.chooseOperator - Date filters: single = inline Calendar, range = From/To DatePicker (stopgap until the date-range calendar lands) - Update tests for the panel-only flow - Rewrite the Filters/FilterConfig docs and refresh the vite example page Co-Authored-By: Claude Opus 4.8 --- .changeset/data-table-filter-redesign.md | 9 +- docs/components/data-table.md | 35 +- .../vite-app/src/pages/data-table/page.tsx | 28 +- packages/core/src/assets/themes/bloom.css | 3 +- packages/core/src/assets/themes/cream.css | 3 +- .../components/data-table/toolbar.test.tsx | 32 +- .../src/components/data-table/toolbar.tsx | 424 ++---------------- packages/core/src/components/menu.tsx | 9 +- packages/core/src/lib/position.ts | 7 - packages/core/src/types/collection.ts | 32 +- 10 files changed, 115 insertions(+), 467 deletions(-) diff --git a/.changeset/data-table-filter-redesign.md b/.changeset/data-table-filter-redesign.md index 087691c8..eeeee94e 100644 --- a/.changeset/data-table-filter-redesign.md +++ b/.changeset/data-table-filter-redesign.md @@ -4,8 +4,7 @@ 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. +- **Add-filter panel**: replaces the old popover + nested selects with a single popover laid out in up to three columns — **field ▸ condition ▸ value**. The condition column appears for fields with more than one operator (number, date/time, string); single-operator fields (enum, uuid) go straight to the value. Values are drafted and committed with an **Apply** button, and the panel stays open so several filters can be added in a row. +- **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-specific editor. +- **Date inputs use app-shell's own components**: single-date operators (exact date / after / before) render the inline `Calendar`; the `is between` range uses two `DatePicker` From/To fields. +- Friendlier operator labels (`is`, `is not`, `is between`, `is any of`, …), multi-select enum values summarized as "N items", compact date ranges, and a consistent primary-colored checkbox across every filter surface. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 5b9c8140..df685ce8 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -147,7 +147,7 @@ function JournalsPage() { | `DataTable.Root` | Context provider. Wraps all other sub-components. Required. | | `DataTable.Table` | Renders the `` with headers and body. Required. | | `DataTable.Toolbar` | Container for toolbar content (e.g. filters, column visibility). Optional. | -| `DataTable.Filters` | Auto-generated filter chips from column filter configs. Requires `control` from `useCollectionVariables`. | +| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | | `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | | `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | @@ -477,7 +477,7 @@ When you spread `...infer("field")`, add `accessor` when you want a typed render ## `FilterConfig` -The `filter` property on a column accepts a `FilterConfig` object. When set, the column appears as an option in `DataTable.Filters` and the filter chip renders an input editor appropriate for the type. +The `filter` property on a column accepts a `FilterConfig` object. When set, the column becomes filterable in `DataTable.Filters` — available in the **Add filter** panel, and rendered as a segmented chip once active. | Property | Type | Description | | --------- | ---------------- | ------------------------------------------------------------ | @@ -485,24 +485,31 @@ The `filter` property on a column accepts a `FilterConfig` object. When set, the | `type` | `FilterType` | Filter editor type (see table below). | | `options` | `SelectOption[]` | Required when `type` is `"enum"`. List of selectable values. | +### Adding and editing filters + +`DataTable.Filters` renders active filters as segmented chips followed by an **Add filter** button: + +- **Add filter panel** — a single popover laid out in up to three columns: **field ▸ condition ▸ value**. The condition column appears for fields with more than one operator (number, date/time, string); single-operator fields (enum, uuid) go straight to the value. Enter a value and click **Apply**; the panel stays open so several filters can be added in a row. +- **Segmented chip** — each active filter shows `field │ operator │ value │ ✕`. The operator segment opens a searchable dropdown to change the condition; the value segment reopens the type-specific editor; `✕` removes the filter. Multi-select enum values are summarized as "N items" (e.g. "2 statuses"). + ### Filter Types and Operators -| Type | Input editor | Supported operators | -| ---------- | -------------- | ------------------------------------------------------------------------------------------------------------ | -| `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | -| `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `date` | **DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** | -| `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `enum` | Dropdown | `eq`, `ne`, `in`, `nin` | -| `boolean` | Toggle | `eq`, `ne` | -| `uuid` | Text | `eq`, `ne`, `in`, `nin` | +| Type | Input editor | Supported operators | +| ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | +| `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** | +| `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `enum` | Dropdown | `eq`, `ne`, `in`, `nin` | +| `boolean` | Toggle | `eq`, `ne` | +| `uuid` | Text | `eq`, `ne`, `in`, `nin` | -When the user selects the `between` operator on a `number`, `datetime`, `date`, or `time` column, the filter chip renders a range input with **min** and **max** bounds. +When the `between` operator is selected on a `number`, `datetime`, `date`, or `time` column, the value editor renders a range input with **min**/**max** (or **From**/**To** for dates) bounds. ### Date Filters -`date` columns use the app-shell [`DatePicker`](./date-picker.md) as the filter input (single value and `between` ranges) and present a friendlier, slimmer operator set: +`date` columns render app-shell date components as the value editor: single-value operators (`eq` / `gte` / `lte`) use an inline `Calendar`, and the `between` range uses two [`DatePicker`](./date-picker.md) **From** / **To** fields. (The range fields are a stopgap — they'll be replaced with a single range calendar once date-range support lands in the date-picker component.) `date` columns also present a friendlier, slimmer operator set: | Operator | Label | Meaning | | --------- | ------------ | -------------------------- | diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index a5008829..c1714c31 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -205,7 +205,8 @@ const columns = [ label: "Due date", render: (row) => dateFormatter.format(new Date(`${row.dueDate}T00:00:00`)), sort: { field: "dueDate", type: "date" }, - // `type: "date"` → the filter editor renders the app-shell DatePicker. + // `type: "date"` → single-date operators render the inline Calendar; the + // "is between" range renders From/To DatePicker fields. filter: { field: "dueDate", type: "date" }, }), ]; @@ -222,8 +223,6 @@ const DataTablePage = () => { const [data, setData] = useState>(); const [loading, setLoading] = useState(true); - // Toggle between the two add-filter UI variants for A/B testing. - const [filterVariant, setFilterVariant] = useState<"menu" | "panel">("menu"); const requestId = useRef(0); useEffect(() => { @@ -249,29 +248,12 @@ const DataTablePage = () => { takes the collection variables{" "} (filter query, order, cursor pagination) — a stand-in for the GraphQL query that would normally drive the table. Add a{" "} - Due date filter to use the DatePicker as the input. - - {/* A/B toggle for the two add-filter variants (demo only). */} -
- Add-filter UI: - {(["menu", "panel"] as const).map((v) => ( - - ))} + Due date filter to pick dates with the inline calendar (or a From/To + range).
- + diff --git a/packages/core/src/assets/themes/bloom.css b/packages/core/src/assets/themes/bloom.css index 3f1834a6..2835473a 100644 --- a/packages/core/src/assets/themes/bloom.css +++ b/packages/core/src/assets/themes/bloom.css @@ -146,7 +146,8 @@ html background-color: transparent; } -html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] { +html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"], +html [data-slot="data-table-filter-chip"] { background-color: transparent; transition: background-color 0.12s ease, diff --git a/packages/core/src/assets/themes/cream.css b/packages/core/src/assets/themes/cream.css index 508d7f21..3d582870 100644 --- a/packages/core/src/assets/themes/cream.css +++ b/packages/core/src/assets/themes/cream.css @@ -146,7 +146,8 @@ html background-color: transparent; } -html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] { +html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"], +html [data-slot="data-table-filter-chip"] { background-color: transparent; transition: background-color 0.12s ease, diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index b9fa1ece..b60f32cc 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -252,16 +252,14 @@ describe("DataTable.Filters", () => { }); // --------------------------------------------------------------------------- -// AddFilterMenu — the add-filter trigger opens a menu of filterable fields +// AddFilterPanel — the add-filter trigger opens a 3-column panel // --------------------------------------------------------------------------- -describe("AddFilterMenu", () => { - it("opens a menu listing the filterable field labels", async () => { - // The old AddFilterPopover (field/operator/value Selects) is gone, replaced - // by a Base UI Menu whose top level lists every filterable field as a - // submenu trigger. Base UI submenu hover-open is unreliable in jsdom, so we - // only assert the top-level field list here rather than driving the full - // field ▸ condition ▸ value flow. +describe("AddFilterPanel", () => { + it("opens a panel listing every filterable field", async () => { + // The add-filter surface is a single popover with three columns + // (field ▸ condition ▸ value); the first column lists each filterable field + // as a button. const user = userEvent.setup(); const control = makeControl({ filters: [] }); render(, { @@ -270,8 +268,22 @@ describe("AddFilterMenu", () => { await user.click(screen.getByRole("button", { name: /Add filter/ })); - expect(await screen.findByRole("menuitem", { name: /Name/ })).toBeDefined(); - expect(screen.getByRole("menuitem", { name: /Count/ })).toBeDefined(); + expect(await screen.findByRole("button", { name: /^Name$/ })).toBeDefined(); + expect(screen.getByRole("button", { name: /^Count$/ })).toBeDefined(); + }); + + it("selecting a field shows the value editor with an Apply button", async () => { + const user = userEvent.setup(); + const control = makeControl({ filters: [] }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + await user.click(await screen.findByRole("button", { name: /^Count$/ })); + + // Amount/Count is numeric → condition column + a value input + Apply. + expect(await screen.findByRole("button", { name: /^Apply$/ })).toBeDefined(); }); }); diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index e7e05ec9..da654d64 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Popover } from "@base-ui/react/popover"; import { Checkbox } from "@base-ui/react/checkbox"; -import { ChevronDown, ChevronRight, Plus, X, Check, Search } from "lucide-react"; +import { ChevronDown, Plus, X, Check, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCollectionControlOptional } from "@/contexts/collection-control-context"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; -import { Menu } from "@/components/menu"; import { Select } from "@/components/select-standalone"; -import { DatePicker, DateField } from "@/components/date-field"; +import { DatePicker } from "@/components/date-field"; +import { Calendar } from "@/components/calendar"; import { parseDate, DateFormatter } from "@internationalized/date"; import { useResolvedLocale } from "@/contexts/appshell-context"; import { useDataTableContext } from "./data-table-context"; @@ -58,28 +58,6 @@ const DEFAULT_OPERATOR: Record = { uuid: "eq", }; -/** - * Whether the add-filter menu shows a condition (operator) step by default for - * a given type. `true` where greater-than / between / before are meaningful; - * `false` where users mostly just pick the value(s). Overridable per field via - * `FilterConfig.chooseOperator`. - */ -const CHOOSE_OPERATOR_DEFAULT: Record = { - number: true, - date: true, - datetime: true, - time: true, - string: false, - enum: false, - boolean: false, - uuid: false, -}; - -/** Resolve whether the add-menu should offer a condition step for this field. */ -function shouldChooseOperator(config: FilterConfig): boolean { - return config.chooseOperator ?? CHOOSE_OPERATOR_DEFAULT[config.type]; -} - /** Number/temporal operators available in the operator selector. */ const NUMERIC_TEMPORAL_OPERATORS = ["eq", "ne", "gt", "gte", "lt", "lte", "between"] as const; type NumericTemporalOperator = (typeof NUMERIC_TEMPORAL_OPERATORS)[number]; @@ -129,18 +107,7 @@ type FilterableColumn = Column> & { type AddFilterDraftValue = string | string[]; /** Use `DataTable.Filters` instead of calling this directly. */ -function DataTableFilters({ - className, - addFilterVariant = "menu", -}: { - className?: string; - /** - * EXPERIMENTAL — the add-filter surface style, for A/B testing. - * - `"menu"` (default): nested-submenu flyout (`AddFilterMenu`). - * - `"panel"`: single 3-column popover (field ▸ condition ▸ value). - */ - addFilterVariant?: "menu" | "panel"; -}) { +function DataTableFilters({ className }: { className?: string }) { const ctx = useDataTableContext(); const control = useCollectionControlOptional(); if (!control) { @@ -169,62 +136,18 @@ function DataTableFilters({ return ; })} - {/* Add-filter surface: the nested-submenu menu (default) or the 3-column panel. */} - {addFilterVariant === "panel" ? ( - - ) : ( - - )} + {/* Add-filter surface: a single popover with three columns (field ▸ condition ▸ value). */} + ); } DataTableFilters.displayName = "DataTable.Filters"; // ============================================================================= -// AddFilterMenu — single Menu flyout: all filterable fields visible, each -// opening a type-specific quick editor in a nested submenu. Fast path uses a -// smart-default operator; operator / between / case-sensitivity are refined on -// the chip (FilterChip). See issue tailor-inc/platform-planning#1509. -// ============================================================================= - -function AddFilterMenu({ - columns, - control, -}: { - columns: FilterableColumn[]; - control: CollectionControl; -}) { - const t = useDataTableT(); - const [open, setOpen] = useState(false); - - return ( - - - - {t("addFilter")} - - } - /> - {/* 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"; - -// ============================================================================= -// AddFilterPanel — EXPERIMENTAL variant B: one popover, three columns -// (field ▸ condition ▸ value) instead of nested submenus. Live-commit: enum -// toggles, text/number commit on Enter, date on pick. Reuses the same value -// editors and helpers as the menu variant. +// AddFilterPanel — the add-filter surface: one popover with three columns +// (field ▸ condition ▸ value). The condition column appears for fields with +// more than one operator. Values are drafted and committed with an Apply button +// (the panel stays open so several filters can be added in a row). // ============================================================================= const PANEL_COLUMN_ROW = cn( @@ -247,9 +170,8 @@ function AddFilterPanel({ const selectedColumn = columns.find((c) => c.filter.field === fieldName) ?? columns[0]; const config = selectedColumn?.filter; const operators = config ? getAddFilterOperators(config.type) : []; - // In the panel there's always room, so show the condition column for any field - // that has more than one operator (the `chooseOperator` flag only gates the - // menu variant). + // Show the condition column for any field that has more than one operator + // (single-operator types like enum/uuid go straight field ▸ value). const showConditions = operators.length > 1; const [operator, setOperator] = useState( @@ -286,8 +208,9 @@ function AddFilterPanel({ @@ -357,10 +280,10 @@ function AddFilterPanel({ AddFilterPanel.displayName = "DataTable.AddFilterPanel"; /** - * Segmented date input for the panel, bridging the filter's ISO string value - * and the `CalendarDate` the field works with. Uses the typed `DateField` (not - * the popover DatePicker or an inline calendar) — both open/re-render inside the - * panel popover and cause it to jump or dismiss. + * Inline calendar for the panel's single-date editor — our `Calendar` rendered + * directly in the value column (no popover to nest inside the panel). The + * pointer-down guard stops the panel's outside-press dismissal from firing when + * a day cell re-renders on selection. */ function PanelDateInput({ ariaLabel, @@ -373,13 +296,16 @@ function PanelDateInput({ }) { const calValue = /^\d{4}-\d{2}-\d{2}$/.test(value) ? parseDate(value) : null; return ( - onChange(v ? v.toString() : "")} - className="astw:w-full" - /> +
e.stopPropagation()} + onMouseDownCapture={(e) => e.stopPropagation()} + > + onChange(v ? v.toString() : "")} + /> +
); } @@ -484,23 +410,23 @@ function PanelValueEditor({ ); } else if (isBetween && type === "date") { - // Inline calendars (not the popover DatePicker) — a popover inside the panel - // popover conflicts with dismissal and causes a jump. + // Range dates: two From/To app-shell DatePickers (segmented input + calendar + // icon/popover). A proper range calendar replaces this once the date-picker + // range PR lands. editor = ( -
- - +
+
+ {t("filterBetweenFrom")} + +
+
+ {t("filterBetweenTo")} + +
); } else if (isBetween) { + // Non-date range: two simple From/To (or Min/Max) text boxes. const numeric = type === "number"; editor = (
@@ -555,260 +481,6 @@ function PanelValueEditor({ ); } -/** - * 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 // ============================================================================= @@ -965,7 +637,9 @@ function FilterChip({ return (
{/* Field segment (icon arrives in Stage 2) */} @@ -1020,7 +694,7 @@ function FilterChip({ } /> - {/* See AddFilterMenu — stacking context so the popup clears the sticky header. */} + {/* Stacking context on the portal so the popup clears the sticky table header. */} & { position?: PositionProps; }) { - const { side = "bottom", align = "start", sideOffset = 4, trackAnchor } = position ?? {}; + const { side = "bottom", align = "start", sideOffset = 4 } = position ?? {}; return ( // Establish a stacking context on the portal container so the menu // renders above fixed elements like sidebar-container (z-index: 10). - + Date: Mon, 20 Jul 2026 18:41:26 +0530 Subject: [PATCH 07/23] fix(data-table): address Copilot review on the filter panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add panel now seeds a field's operator/value/caseSensitive from an active filter, so re-opening a filtered field and hitting Apply preserves it instead of resetting to the default (which could overwrite or remove the filter) - Localize the enum count summary via a `filterEnumCount` i18n label (en plural, ja counter) — no more "2 ステータスs" - Guard the chip's operator switch against NaN/Infinity when seeding numbers - Localize the operator-search empty state (`filterOperatorNoResults`) and render it as for assistive tech - Add regression tests for the panel seeding + case-sensitivity preservation Co-Authored-By: Claude Opus 4.8 --- .../core/src/components/data-table/i18n.ts | 15 ++++++ .../components/data-table/toolbar.test.tsx | 33 ++++++++++++ .../src/components/data-table/toolbar.tsx | 52 +++++++++++++------ 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 00cca8e1..02369659 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -1,5 +1,13 @@ import { defineI18nLabels } from "@/hooks/i18n"; +/** English plural of a column label for enum count summaries ("Status" → "statuses"). */ +function pluralizeEn(label: string): string { + const w = label.toLowerCase(); + if (/(s|x|z|ch|sh)$/.test(w)) return `${w}es`; + if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ies`; + return `${w}s`; +} + export const dataTableLabels = defineI18nLabels({ en: { // DataTable.Body @@ -34,7 +42,11 @@ export const dataTableLabels = defineI18nLabels({ applyFilter: "Apply", removeFilter: "Remove filter", filterOperatorSearchPlaceholder: "Search...", + filterOperatorNoResults: "No results", filterValuePlaceholder: (props: { field: string }) => `Enter ${props.field.toLowerCase()}`, + // Multi-select enum chip summary, e.g. "2 statuses". + filterEnumCount: (props: { count: number; noun: string }) => + `${props.count} ${pluralizeEn(props.noun)}`, filterBooleanTrue: "True", filterBooleanFalse: "False", filterOperator_eq: "is", @@ -93,7 +105,10 @@ export const dataTableLabels = defineI18nLabels({ applyFilter: "適用", removeFilter: "フィルタを削除", filterOperatorSearchPlaceholder: "検索...", + filterOperatorNoResults: "該当なし", filterValuePlaceholder: (props: { field: string }) => `${props.field}を入力`, + // Multi-select enum chip summary, e.g. "ステータス2件". + filterEnumCount: (props: { count: number; noun: string }) => `${props.noun}${props.count}件`, 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 b60f32cc..caa0e569 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -285,6 +285,39 @@ describe("AddFilterPanel", () => { // Amount/Count is numeric → condition column + a value input + Apply. expect(await screen.findByRole("button", { name: /^Apply$/ })).toBeDefined(); }); + + it("seeds an already-filtered field's operator/value so Apply preserves them", async () => { + // Re-opening the panel on a field that already has a non-default filter + // (here a number "between") must keep that operator and value rather than + // resetting to the type default — otherwise Apply would silently overwrite. + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "count", operator: "between", value: { min: 5, max: 10 } }], + }); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + await user.click(await screen.findByRole("button", { name: /^Apply$/ })); + + expect(control.addFilter).toHaveBeenCalledWith("count", "between", { min: 5, max: 10 }); + }); + + it("preserves case-sensitivity when re-applying a string filter from the panel", async () => { + // The panel has no case-sensitive toggle; re-applying must carry the active + // filter's caseSensitive flag through rather than clearing it. + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "name", operator: "contains", value: "Alice", caseSensitive: true }], + }); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + await user.click(await screen.findByRole("button", { name: /^Apply$/ })); + + expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Alice", { + caseSensitive: true, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index da654d64..abb44205 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -156,6 +156,24 @@ const PANEL_COLUMN_ROW = cn( "astw:hover:bg-accent astw:hover:text-accent-foreground astw:focus-visible:bg-accent", ); +/** + * Seed the add-panel's operator for a field: reuse an active filter's operator + * when it's valid for the field type, so re-opening an already-filtered field + * shows its current condition (and lets the value editor prefill) instead of + * resetting to the default — which on Apply could silently overwrite the filter. + * Falls back to the type default when there's no active filter. + */ +function seedPanelOperator( + control: CollectionControl, + col: FilterableColumn | undefined, +): FilterOperator { + if (!col) return "eq"; + const active = control.filters.find((f) => f.field === col.filter.field); + const ops = getAddFilterOperators(col.filter.type); + if (active && ops.includes(active.operator)) return active.operator; + return DEFAULT_OPERATOR[col.filter.type]; +} + function AddFilterPanel({ columns, control, @@ -174,14 +192,14 @@ function AddFilterPanel({ // (single-operator types like enum/uuid go straight field ▸ value). const showConditions = operators.length > 1; - const [operator, setOperator] = useState( - config ? DEFAULT_OPERATOR[config.type] : "eq", + const [operator, setOperator] = useState(() => + seedPanelOperator(control, selectedColumn), ); const selectField = (name: string) => { setFieldName(name); const col = columns.find((c) => c.filter.field === name); - if (col) setOperator(DEFAULT_OPERATOR[col.filter.type]); + if (col) setOperator(seedPanelOperator(control, col)); }; const activeFields = new Set(control.filters.map((f) => f.field)); @@ -375,7 +393,9 @@ function PanelValueEditor({ field, operator, toAddFilterSubmittedValue(type, operator, text), - type === "string" ? { caseSensitive: false } : undefined, + // Preserve the existing filter's case-sensitivity (the panel has no toggle; + // the chip's string editor owns it) instead of silently clearing it. + type === "string" ? { caseSensitive: filter?.caseSensitive ?? false } : undefined, ); }; @@ -592,6 +612,12 @@ function FilterChip({ // malformed. Fine-tuning the value happens in the value segment. const handleOperatorSelect = useCallback( (nextOp: FilterOperator) => { + // Coerce to a finite number, falling back to 0 for malformed values (e.g. + // a non-numeric value from persisted/URL state) so we never seed NaN. + const toNum = (v: unknown) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; const arity = (op: FilterOperator) => (op === "between" ? 2 : 1); if (arity(nextOp) === arity(filter.operator)) { control.addFilter( @@ -603,7 +629,7 @@ function FilterChip({ } else if (nextOp === "between") { const v = filter.value; if (config.type === "number") { - const n = typeof v === "number" ? v : Number(v); + const n = toNum(v); control.addFilter(config.field, nextOp, { min: n, max: n }); } else { const s = v == null ? "" : String(v); @@ -615,7 +641,7 @@ function FilterChip({ control.addFilter( config.field, nextOp, - config.type === "number" ? Number(lower) : String(lower), + config.type === "number" ? toNum(lower) : String(lower), ); } setOpOpen(false); @@ -790,7 +816,9 @@ function OperatorList({ ))} {items.length === 0 && ( -
+ + {t("filterOperatorNoResults")} + )}
@@ -1654,14 +1682,6 @@ function formatDateValue(iso: string, locale: string): string { } } -/** Pluralize a column label for enum count summaries ("Status" → "statuses"). */ -function pluralizeNoun(label: string): string { - const w = label.toLowerCase(); - if (/(s|x|z|ch|sh)$/.test(w)) return `${w}es`; - if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ies`; - return `${w}s`; -} - /** * Date range for chip display: "15 Jul 2026 – 17 Jul 2026". Each bound is * formatted with the same locale-aware medium date used elsewhere, joined with @@ -1688,7 +1708,7 @@ function formatFilterValue( .filter((v) => v !== ""); // Summarize multiple selections as "2 statuses" rather than listing them. if (labels.length > 1 && label) { - return `${labels.length} ${pluralizeNoun(label)}`; + return t("filterEnumCount", { count: labels.length, noun: label }); } return labels.join(", "); } From d8b5e376337db912b13def8261371a4c2210e677 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 21 Jul 2026 14:19:49 +0530 Subject: [PATCH 08/23] =?UTF-8?q?feat(data-table):=20filter=20panel=20poli?= =?UTF-8?q?sh=20=E2=80=94=20clear=20actions,=20inline=20range=20error,=20l?= =?UTF-8?q?ayout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reversed "between" range shows an inline error ("Max must be greater than or equal to Min") beneath the inputs and keeps the commit disabled - Add a per-field Clear (icon-only, tooltip) beside Apply/Update and a sticky "Clear all filters" at the bottom of the field column (shown only when active) - Right-align the Add filter trigger (funnel icon) so it stays put as chips are added; panel right-aligns to it with a fixed width so it never jumps between 2- and 3-column fields and fits the inline calendar (now centered) - Soften the Min/Max label cells (bg-muted) so they aren't near-black in dark mode Co-Authored-By: Claude Opus 4.8 --- .../core/src/components/data-table/i18n.ts | 10 + .../components/data-table/toolbar.test.tsx | 38 ++- .../src/components/data-table/toolbar.tsx | 301 ++++++++++++++---- 3 files changed, 285 insertions(+), 64 deletions(-) diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 02369659..94e570b2 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -40,7 +40,10 @@ export const dataTableLabels = defineI18nLabels({ // Filters addFilter: "Add filter", applyFilter: "Apply", + updateFilter: "Update", removeFilter: "Remove filter", + clearFilter: "Clear this filter", + clearAllFilters: "Clear all filters", filterOperatorSearchPlaceholder: "Search...", filterOperatorNoResults: "No results", filterValuePlaceholder: (props: { field: string }) => `Enter ${props.field.toLowerCase()}`, @@ -73,6 +76,8 @@ export const dataTableLabels = defineI18nLabels({ filterBetweenTo: "To", filterBetweenMin: "Min", filterBetweenMax: "Max", + filterBetweenOrderError: (props: { min: string; max: string }) => + `${props.max} must be greater than or equal to ${props.min}`, filterCaseSensitive: "Case sensitive", // Filter chip label templates @@ -103,7 +108,10 @@ export const dataTableLabels = defineI18nLabels({ // Filters addFilter: "フィルタを追加", applyFilter: "適用", + updateFilter: "更新", removeFilter: "フィルタを削除", + clearFilter: "このフィルタをクリア", + clearAllFilters: "すべてのフィルタをクリア", filterOperatorSearchPlaceholder: "検索...", filterOperatorNoResults: "該当なし", filterValuePlaceholder: (props: { field: string }) => `${props.field}を入力`, @@ -133,6 +141,8 @@ export const dataTableLabels = defineI18nLabels({ filterBetweenTo: "終了", filterBetweenMin: "最小", filterBetweenMax: "最大", + filterBetweenOrderError: (props: { min: string; max: string }) => + `${props.max}は${props.min}以上にしてください`, filterCaseSensitive: "大文字小文字を区別する", // Filter chip label templates (Japanese: column: value operator) diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index caa0e569..b94a6790 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -286,10 +286,10 @@ describe("AddFilterPanel", () => { expect(await screen.findByRole("button", { name: /^Apply$/ })).toBeDefined(); }); - it("seeds an already-filtered field's operator/value so Apply preserves them", async () => { + it("seeds an already-filtered field's operator/value so the panel preserves them", async () => { // Re-opening the panel on a field that already has a non-default filter // (here a number "between") must keep that operator and value rather than - // resetting to the type default — otherwise Apply would silently overwrite. + // resetting to the type default — otherwise applying would silently overwrite. const user = userEvent.setup(); const control = makeControl({ filters: [{ field: "count", operator: "between", value: { min: 5, max: 10 } }], @@ -297,7 +297,8 @@ describe("AddFilterPanel", () => { render(, { wrapper }); await user.click(screen.getByRole("button", { name: /Add filter/ })); - await user.click(await screen.findByRole("button", { name: /^Apply$/ })); + // The commit button reads "Update" (not "Add"/"Apply") for an active field. + await user.click(await screen.findByRole("button", { name: /^Update$/ })); expect(control.addFilter).toHaveBeenCalledWith("count", "between", { min: 5, max: 10 }); }); @@ -312,12 +313,41 @@ describe("AddFilterPanel", () => { render(, { wrapper }); await user.click(screen.getByRole("button", { name: /Add filter/ })); - await user.click(await screen.findByRole("button", { name: /^Apply$/ })); + await user.click(await screen.findByRole("button", { name: /^Update$/ })); expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Alice", { caseSensitive: true, }); }); + + it("disables the commit button when the between range is reversed (min > max)", async () => { + const user = userEvent.setup(); + const control = makeControl({ filters: [] }); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + // Choose "is between", then enter a reversed range. + await user.click(await screen.findByRole("button", { name: /is between/i })); + await user.type(screen.getByRole("spinbutton", { name: "Min" }), "10"); + await user.type(screen.getByRole("spinbutton", { name: "Max" }), "5"); + + const commit = screen.getByRole("button", { name: /^(Apply|Update)$/ }); + expect((commit as HTMLButtonElement).disabled).toBe(true); + // ...and an inline error explains why. + expect(screen.getByText(/must be greater than or equal to/i)).toBeDefined(); + }); + + it("renders a native time input for a single-value time filter", async () => { + const user = userEvent.setup(); + const control = makeControl({ filters: [] }); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + // `time` defaults to a single-value operator → native time input (not a + // plain text box). + const input = await screen.findByLabelText("Opens At"); + expect(input.getAttribute("type")).toBe("time"); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index abb44205..de516716 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Popover } from "@base-ui/react/popover"; import { Checkbox } from "@base-ui/react/checkbox"; -import { ChevronDown, Plus, X, Check, Search } from "lucide-react"; +import { ChevronDown, Filter as FilterIcon, X, Check, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCollectionControlOptional } from "@/contexts/collection-control-context"; import { Button } from "@/components/button"; @@ -9,6 +9,7 @@ import { Input } from "@/components/input"; import { Select } from "@/components/select-standalone"; import { DatePicker } from "@/components/date-field"; import { Calendar } from "@/components/calendar"; +import { Tooltip } from "@/components/tooltip"; import { parseDate, DateFormatter } from "@internationalized/date"; import { useResolvedLocale } from "@/contexts/appshell-context"; import { useDataTableContext } from "./data-table-context"; @@ -127,16 +128,22 @@ function DataTableFilters({ className }: { className?: string }) { return (
- {/* Active filter chips */} - {filterableColumns.map((col) => { - const active = control.filters.find((f) => f.field === col.filter.field); - if (!active) return null; - return ; - })} - - {/* Add-filter surface: a single popover with three columns (field ▸ condition ▸ value). */} + {/* Active filter chips (grow to fill; wrap as needed) */} +
+ {filterableColumns.map((col) => { + const active = control.filters.find((f) => f.field === col.filter.field); + if (!active) return null; + return ( + + ); + })} +
+ + {/* Right-aligned action(s): the add-filter panel trigger stays pinned to the + right so it doesn't shift as chips are added (and sits alongside a future + column-settings button). */}
); @@ -214,45 +221,67 @@ function AddFilterPanel({ - + {t("addFilter")} } /> - {/* disableAnchorTracking pins the panel where it opened so adding a chip - (which shifts the trigger) doesn't make it jump — same as the menu. */} - + {/* align="end" anchors the panel's right edge to the (right-aligned) trigger + so it grows/shrinks toward the right as columns appear/disappear. We keep + anchor tracking on (no disableAnchorTracking) so the positioner re-aligns + the right edge when the width changes; the trigger itself no longer moves + when chips are added (they live in a separate flex-1 container), so there's + nothing to jump away from. */} + - {/* Column 1 — fields */} -
- {columns.map((col) => { - const isSelected = col.filter.field === fieldName; - return ( - + ); + })} +
+ {control.filters.length > 0 && ( +
+ - ); - })} + {t("clearAllFilters")} + +
+ )}
{/* Column 2 — conditions (only when the field opts into choosing one) */} @@ -277,8 +306,9 @@ function AddFilterPanel({ )} - {/* Column 3 — value editor for the chosen field + condition */} -
+ {/* Column 3 — value editor; flex-1 so it absorbs the width freed when + the condition column is hidden, keeping the popup width constant. */} +
{selectedColumn && effectiveOperator && ( e.stopPropagation()} onMouseDownCapture={(e) => e.stopPropagation()} > @@ -381,6 +413,7 @@ function PanelValueEditor({ if (isBetween) { const draft: AddFilterDraftValue = [min, max]; if (!isAddFilterDraftValueValid(type, "between", draft)) return; + if (!isRangeOrdered(type, min, max)) return; control.addFilter(field, "between", toAddFilterSubmittedValue(type, "between", draft)); return; } @@ -399,6 +432,34 @@ function PanelValueEditor({ ); }; + // Remove this field's active filter and reset the draft. Shown only while a + // filter is active (there's nothing to clear on a fresh add). + const clearThis = () => { + control.removeFilter(field); + setEnumSel([]); + setBoolVal(true); + setText(""); + setMin(""); + setMax(""); + }; + + // Gate the Apply button so it's disabled on invalid input (rather than enabled + // but inert). An empty draft is allowed only when it clears an existing filter. + const canApply = (() => { + if (type === "enum") return enumSel.length > 0 || !!filter; + if (type === "boolean") return true; + if (isBetween) { + const minEmpty = min.trim() === ""; + const maxEmpty = max.trim() === ""; + if (minEmpty && maxEmpty) return !!filter; + if (minEmpty || maxEmpty) return false; + if (!isAddFilterDraftValueValid(type, "between", [min, max])) return false; + return isRangeOrdered(type, min, max); + } + if (text.trim() === "") return !!filter; + return isAddFilterDraftValueValid(type, operator, text); + })(); + let editor: ReactNode; if (type === "enum") { editor = ( @@ -443,19 +504,23 @@ function PanelValueEditor({ {t("filterBetweenTo")}
+ {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t) && ( +

+ {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t)} +

+ )}
); } else if (isBetween) { // Non-date range: two simple From/To (or Min/Max) text boxes. const numeric = type === "number"; + const labels: [string, string] = numeric + ? [t("filterBetweenMin"), t("filterBetweenMax")] + : [t("filterBetweenFrom"), t("filterBetweenTo")]; editor = (
); @@ -472,6 +538,24 @@ function PanelValueEditor({ ); + } else if (isTemporalFilterType(type)) { + // Single-value datetime/time use the native temporal input (a time picker for + // `time`; an RFC text box for `datetime` until a DateTime picker lands) so the + // panel matches the chip editor and the between inputs. (`date` handled above.) + editor = ( +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") apply(); + }} + className="astw:h-8 astw:text-sm" + /> +
+ ); } else { editor = (
@@ -492,9 +576,31 @@ function PanelValueEditor({ return (
{editor}
-
- + } + /> + {t("clearFilter")} + + + )} + {/* "Update" when re-editing an already-active field (the panel is seeded + from it), "Apply" when adding a fresh filter. */} +
@@ -512,6 +618,7 @@ function BetweenInputGroup({ onChangeMax, onSubmit, inputProps, + error, }: { labels: [string, string]; values: [string, string]; @@ -519,13 +626,22 @@ function BetweenInputGroup({ onChangeMax: (value: string) => void; onSubmit: () => void; inputProps?: React.ComponentProps; + /** Validation message shown below the inputs (e.g. a reversed range). */ + error?: string; }) { + const rowBase = + "astw:flex astw:items-center astw:h-8 astw:rounded-md astw:border astw:shadow-xs astw:has-focus-visible:ring-[3px]"; + const rowOk = + "astw:border-input astw:has-focus-visible:border-ring astw:has-focus-visible:ring-ring/50"; + const rowError = "astw:border-destructive astw:has-focus-visible:ring-destructive/30"; + const labelCell = + "astw:text-secondary-foreground astw:text-xs astw:px-2.5 astw:border-r astw:border-input astw:bg-muted astw:rounded-l-md astw:h-full astw:flex astw:items-center astw:justify-center astw:shrink-0 astw:min-w-14"; + const inputCell = + "astw:h-full astw:text-sm astw:border-0 astw:shadow-none astw:focus-visible:ring-0"; return (
-
- - {labels[0]} - +
+ {labels[0]}
-
- - {labels[1]} - +
+ {labels[1]} 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" + className={inputCell} />
+ {error &&

{error}

}
); } +/** + * Validation message for a "between" range when the bounds are reversed + * (max < min). Returns undefined until both bounds are present and individually + * valid, so it only appears once there's a genuine ordering problem. + */ +function betweenOrderError( + type: FilterConfig["type"], + min: string, + max: string, + minLabel: string, + maxLabel: string, + t: ReturnType, +): string | undefined { + if (min.trim() === "" || max.trim() === "") return undefined; + let bothValid = true; + if (type === "number") { + bothValid = !Number.isNaN(Number(min)) && !Number.isNaN(Number(max)); + } else if (isTemporalFilterType(type)) { + bothValid = isTemporalFilterValueValid(type, min) && isTemporalFilterValueValid(type, max); + } + if (!bothValid) return undefined; + if (isRangeOrdered(type, min, max)) return undefined; + return t("filterBetweenOrderError", { min: minLabel, max: maxLabel }); +} + /** * Date filter input backed by the app-shell `DatePicker`. Bridges the filter's * string value (`"YYYY-MM-DD"`) and the `CalendarDate` the picker works with. @@ -1246,7 +1387,11 @@ function NumericFilterEditor({ const maxEmpty = localValueMax.trim() === ""; if (minEmpty && maxEmpty) return true; // will removeFilter if (minEmpty || maxEmpty) return false; // both required - return !Number.isNaN(Number(localValue)) && !Number.isNaN(Number(localValueMax)); + return ( + !Number.isNaN(Number(localValue)) && + !Number.isNaN(Number(localValueMax)) && + isRangeOrdered("number", localValue, localValueMax) + ); } return localValue.trim() === "" || !Number.isNaN(Number(localValue)); })(); @@ -1260,7 +1405,7 @@ function NumericFilterEditor({ } else if (!minEmpty && !maxEmpty) { const min = Number(localValue); const max = Number(localValueMax); - if (!Number.isNaN(min) && !Number.isNaN(max)) { + if (!Number.isNaN(min) && !Number.isNaN(max) && min <= max) { control.addFilter(config.field, localOp, { min, max }); } else { return; @@ -1303,6 +1448,14 @@ function NumericFilterEditor({ onChangeMax={setLocalValueMax} onSubmit={handleCommit} inputProps={{ type: "number" }} + error={betweenOrderError( + "number", + localValue, + localValueMax, + t("filterBetweenMin"), + t("filterBetweenMax"), + t, + )} /> ) : ( + {betweenError &&

{betweenError}

}
) : ( ); } else { @@ -1583,6 +1751,19 @@ function isTemporalFilterType(type: FilterConfig["type"]): type is "datetime" | return type === "datetime" || type === "date" || type === "time"; } +/** + * Whether a "between" range's bounds are correctly ordered (min ≤ max). Numbers + * compare numerically; temporal ISO strings compare lexicographically (which + * matches chronological order for our `YYYY-MM-DD`, `HH:MM`, and RFC datetime + * formats). `min === max` is allowed — a valid single-point inclusive range. + * Assumes both bounds are already individually valid and non-empty. + */ +function isRangeOrdered(type: FilterConfig["type"], min: string, max: string): boolean { + if (type === "number") return Number(min) <= Number(max); + if (isTemporalFilterType(type)) return min <= max; + return true; +} + function isTemporalFilterValueValid(type: "datetime" | "date" | "time", value: string): boolean { const trimmedValue = value.trim(); if (trimmedValue === "") return false; From bd1f37e1cb8e40a0167aff3eed139611ab4de4ed Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 21 Jul 2026 19:14:39 +0530 Subject: [PATCH 09/23] feat(data-table): datetime picker (date + time box), full-type demo, chip polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Datetime filter now uses a date DatePicker + native time box (combined into a local "YYYY-MM-DDTHH:mm:ss") instead of a raw ISO text field — single and between. Stopgap until a dedicated DateTime picker component lands (swaps 1:1). - Relax the datetime validator to accept the local no-zone value; format the datetime chip value as a locale date + time to match the column renderer. - Truncate long chip values (uuid / long strings) so they don't stretch the toolbar. - Expand the vite demo to one column per supported filter type (adds uuid, boolean, datetime, time) so every editor/operator path is exercisable. Co-Authored-By: Claude Opus 4.8 --- .../vite-app/src/pages/data-table/page.tsx | 61 +++++- .../components/data-table/toolbar.test.tsx | 30 +-- .../src/components/data-table/toolbar.tsx | 207 ++++++++++++++---- 3 files changed, 229 insertions(+), 69 deletions(-) diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index c1714c31..6ff9bddc 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -20,13 +20,23 @@ type InvoiceStatus = "draft" | "sent" | "paid" | "overdue"; // A `type` (not `interface`) so it satisfies `Record` — // `createColumnHelper`/`useDataTable`'s row constraint. Interfaces lack the // implicit index signature that type aliases of object literals have. +// One field per supported filter type so every editor/operator path is exercised: +// string, number, enum, boolean, uuid, date, datetime, time. type Invoice = { id: string; + /** uuid — matches `type: "uuid"` (eq / in). */ + externalId: string; customer: string; amount: number; status: InvoiceStatus; - /** ISO date, "YYYY-MM-DD" — matches what the date filter / DatePicker emit. */ + /** boolean — matches `type: "boolean"` (is / is not). */ + recurring: boolean; + /** ISO date, "YYYY-MM-DD" — matches what the date filter / Calendar emit. */ dueDate: string; + /** ISO datetime with a `Z` offset, "YYYY-MM-DDTHH:mm:ssZ" — `type: "datetime"`. */ + createdAt: string; + /** 24h time, "HH:mm" — matches the native time input / `type: "time"`. */ + reminderAt: string; }; const CUSTOMERS = [ @@ -48,16 +58,28 @@ function makeInvoices(count: number): Invoice[] { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; + const hex = (n: number) => + Array.from({ length: n }, () => Math.floor(rand() * 16).toString(16)).join(""); const base = new Date("2026-01-01T00:00:00Z").getTime(); for (let i = 0; i < count; i++) { const dayOffset = Math.floor(rand() * 270); // ~9 months spread - const d = new Date(base + dayOffset * 86_400_000); + const hour = Math.floor(rand() * 24); + const minute = Math.floor(rand() * 60); + const dueMs = base + dayOffset * 86_400_000; + const createdMs = dueMs + hour * 3_600_000 + minute * 60_000; + const pad = (n: number) => String(n).padStart(2, "0"); rows.push({ id: `INV-${String(1000 + i)}`, + externalId: `${hex(8)}-${hex(4)}-${hex(4)}-${hex(4)}-${hex(12)}`, customer: CUSTOMERS[Math.floor(rand() * CUSTOMERS.length)], amount: Math.round((rand() * 9000 + 100) * 100) / 100, status: STATUSES[Math.floor(rand() * STATUSES.length)], - dueDate: d.toISOString().slice(0, 10), + recurring: rand() > 0.5, + dueDate: new Date(dueMs).toISOString().slice(0, 10), + // Local "YYYY-MM-DDTHH:mm:ss" (no zone) — matches what the datetime filter + // editor (date picker + time box) emits, so string comparison lines up. + createdAt: new Date(createdMs).toISOString().slice(0, 19), + reminderAt: `${pad(hour)}:${pad(minute)}`, }); } return rows; @@ -167,6 +189,10 @@ const dateFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", }); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); const moneyFormatter = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }); const statusVariant = (status: InvoiceStatus) => @@ -180,6 +206,12 @@ const statusVariant = (status: InvoiceStatus) => const columns = [ column({ label: "Invoice", render: (row) => row.id }), + // uuid → text input, eq only. + column({ + label: "Ref", + render: (row) => {row.externalId.slice(0, 8)}…, + filter: { field: "externalId", type: "uuid" }, + }), column({ label: "Customer", render: (row) => row.customer, @@ -201,6 +233,16 @@ const columns = [ options: STATUSES.map((s) => ({ value: s, label: s })), }, }), + // boolean → is / is not, True/False picker. + column({ + label: "Recurring", + render: (row) => ( + + {row.recurring ? "Yes" : "No"} + + ), + filter: { field: "recurring", type: "boolean" }, + }), column({ label: "Due date", render: (row) => dateFormatter.format(new Date(`${row.dueDate}T00:00:00`)), @@ -209,6 +251,19 @@ const columns = [ // "is between" range renders From/To DatePicker fields. filter: { field: "dueDate", type: "date" }, }), + // datetime → full numeric operator set; value is a strict ISO datetime string. + column({ + label: "Created", + render: (row) => dateTimeFormatter.format(new Date(row.createdAt)), + sort: { field: "createdAt", type: "date" }, + filter: { field: "createdAt", type: "datetime" }, + }), + // time → native time input, "HH:mm". + column({ + label: "Reminder", + render: (row) => row.reminderAt, + filter: { field: "reminderAt", type: "time" }, + }), ]; // ─── Page ────────────────────────────────────────────────────────────────────── diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index b94a6790..3c271866 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, it, expect, vi } from "vitest"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { DataTable } from "./data-table"; @@ -760,7 +760,7 @@ describe("DateFilterEditor", () => { // --------------------------------------------------------------------------- describe("TemporalFilterEditor", () => { - it("Apply button calls addFilter with a full RFC3339 datetime", async () => { + it("renders a date picker + time box for datetime (seeded, no raw ISO textbox)", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00Z" }], @@ -771,19 +771,13 @@ describe("TemporalFilterEditor", () => { await openValueEditor(user); - const input = await screen.findByDisplayValue("2025-01-01T10:30:00Z"); - await user.clear(input); - await user.type(input, "2026-06-15T08:45:30+09:00"); - await user.click(screen.getByRole("button", { name: "Apply" })); - - expect(control.addFilter).toHaveBeenCalledWith( - "publishedAt", - "eq", - "2026-06-15T08:45:30+09:00", - ); + // Date part is a segmented picker (a group), time part a native time box + // seeded from the value — no free-text ISO field. + expect(await screen.findByRole("group")).toBeDefined(); + expect(screen.getByDisplayValue("10:30")).toBeDefined(); }); - it("Apply button stays disabled for an invalid RFC3339 datetime", async () => { + it("combines the date + time box into an ISO datetime on Apply", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00Z" }], @@ -794,13 +788,11 @@ describe("TemporalFilterEditor", () => { await openValueEditor(user); - const input = await screen.findByDisplayValue("2025-01-01T10:30:00Z"); - await user.clear(input); - await user.type(input, "2026-06-15T08:45"); + fireEvent.change(await screen.findByDisplayValue("10:30"), { target: { value: "08:45" } }); + await user.click(screen.getByRole("button", { name: "Apply" })); - expect((screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement).disabled).toBe( - true, - ); + // Date kept, time replaced, seconds defaulted → local ISO (no zone). + expect(control.addFilter).toHaveBeenCalledWith("publishedAt", "eq", "2025-01-01T08:45:00"); }); it("Apply button calls addFilter with an HH:MM time", async () => { diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index de516716..ec44a814 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -511,6 +511,25 @@ function PanelValueEditor({ )}
); + } else if (isBetween && type === "datetime") { + // Range datetimes: two From/To date-pickers each paired with a time box. + editor = ( +
+
+ {t("filterBetweenFrom")} + +
+
+ {t("filterBetweenTo")} + +
+ {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t) && ( +

+ {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t)} +

+ )} +
+ ); } else if (isBetween) { // Non-date range: two simple From/To (or Min/Max) text boxes. const numeric = type === "number"; @@ -538,10 +557,15 @@ function PanelValueEditor({
); + } else if (type === "datetime") { + // Single datetime: date-picker + time box (combined into an ISO string). + editor = ( +
+ +
+ ); } else if (isTemporalFilterType(type)) { - // Single-value datetime/time use the native temporal input (a time picker for - // `time`; an RFC text box for `datetime` until a DateTime picker lands) so the - // panel matches the chip editor and the between inputs. (`date` handled above.) + // Single-value `time` uses the native time input. (`date`/`datetime` above.) editor = (
void; +}) { + // Split "YYYY-MM-DDTHH:mm[:ss][Z]" into its date and "HH:mm" parts. + const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}))?/); + const datePart = match?.[1] ?? ""; + const timePart = match?.[2] ?? ""; + const calValue = /^\d{4}-\d{2}-\d{2}$/.test(datePart) ? parseDate(datePart) : null; + + // Emit a combined value only once a date is chosen; time defaults to midnight. + const emit = (nextDate: string, nextTime: string) => { + onChange(nextDate ? `${nextDate}T${nextTime || "00:00"}:00` : ""); + }; + + return ( +
+ emit(v ? v.toString() : "", timePart)} + className="astw:min-w-0 astw:flex-1" + /> + emit(datePart, e.target.value)} + className="astw:h-8 astw:w-28 astw:shrink-0 astw:text-sm" + /> +
+ ); +} + // ============================================================================= // FilterChip — per-filter popover-based editor // ============================================================================= @@ -856,7 +926,13 @@ function FilterChip({ type="button" className={cn(interactiveSegment, "astw:gap-1 astw:font-medium astw:text-foreground")} > - {valueLabel || } + {valueLabel ? ( + // Cap long values (e.g. a uuid or a long "contains" string) so the + // chip stays a reasonable width instead of stretching the toolbar. + {valueLabel} + ) : ( + + )} } @@ -1564,6 +1640,9 @@ function TemporalFilterEditor({ }, [localValue, localValueMax, localOp, control, config.field, config.type, onClose]); const isDate = config.type === "date"; + const isDateTime = config.type === "datetime"; + // date + datetime pickers share the same { ariaLabel, value, onChange } shape. + const Picker = isDateTime ? DateTimeFilterInput : DateFilterPicker; const betweenError = localOp === "between" ? betweenOrderError( @@ -1577,50 +1656,52 @@ function TemporalFilterEditor({ : undefined; let valueInput: ReactNode; if (localOp === "between") { - valueInput = isDate ? ( -
- - + + + {betweenError &&

{betweenError}

} +
+ ) : ( + - {betweenError &&

{betweenError}

} -
- ) : ( - - ); + ); } else { - valueInput = isDate ? ( - - ) : ( - { - setLocalValue(e.target.value); - }} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - handleCommit(); - } - }} - className="astw:h-8 astw:text-sm" - /> - ); + valueInput = + isDate || isDateTime ? ( + + ) : ( + { + setLocalValue(e.target.value); + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + handleCommit(); + } + }} + className="astw:h-8 astw:text-sm" + /> + ); } return ( @@ -1770,7 +1851,9 @@ function isTemporalFilterValueValid(type: "datetime" | "date" | "time", value: s switch (type) { case "datetime": - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + // The datetime editor emits a local "YYYY-MM-DDTHH:mm:ss" (no zone); a + // trailing Z or ±hh:mm offset is still accepted for externally-set values. + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test( trimmedValue, ); case "date": @@ -1875,6 +1958,24 @@ function formatDateRange(minIso: string, maxIso: string, locale: string): string .join(" – "); } +/** Format a local "YYYY-MM-DDTHH:mm[:ss]" as a locale medium date + short time. */ +function formatDateTimeValue(iso: string, locale: string): string { + const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); + if (!m) return iso; + const [, y, mo, d, h, min] = m; + try { + return new DateFormatter(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(min))); + } catch { + return iso; + } +} + function formatFilterValue( filter: Filter, config: FilterConfig, @@ -1918,6 +2019,18 @@ function formatFilterValue( return formatDateValue(String(filter.value), locale); } + if (config.type === "datetime") { + if (filter.operator === "between") { + const range = filter.value as { min?: unknown; max?: unknown } | null; + if (!range || typeof range !== "object") return ""; + const min = range.min != null ? formatDateTimeValue(String(range.min), locale) : ""; + const max = range.max != null ? formatDateTimeValue(String(range.max), locale) : ""; + return [min, max].filter(Boolean).join(" – "); + } + if (filter.value == null || filter.value === "") return ""; + return formatDateTimeValue(String(filter.value), locale); + } + if (isTemporalFilterType(config.type) && filter.operator === "between") { const range = filter.value as { min?: unknown; max?: unknown } | null; if (!range || typeof range !== "object") return ""; From 341732949b14e97ba00e482763455757300d9055 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 21 Jul 2026 19:19:37 +0530 Subject: [PATCH 10/23] feat(data-table): single-datetime panel editor uses inline calendar + time Match the single-date experience: the panel's single-datetime editor now shows the inline Calendar up front with a labelled "Choose time" picker beneath it, instead of the compact segmented date field. The chip editor and the "between" range keep the compact date-picker + time box so they stay short. Co-Authored-By: Claude Opus 4.8 --- .../core/src/components/data-table/i18n.ts | 2 + .../src/components/data-table/toolbar.tsx | 54 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 94e570b2..e829859e 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -44,6 +44,7 @@ export const dataTableLabels = defineI18nLabels({ removeFilter: "Remove filter", clearFilter: "Clear this filter", clearAllFilters: "Clear all filters", + chooseTime: "Choose time", filterOperatorSearchPlaceholder: "Search...", filterOperatorNoResults: "No results", filterValuePlaceholder: (props: { field: string }) => `Enter ${props.field.toLowerCase()}`, @@ -112,6 +113,7 @@ export const dataTableLabels = defineI18nLabels({ removeFilter: "フィルタを削除", clearFilter: "このフィルタをクリア", clearAllFilters: "すべてのフィルタをクリア", + chooseTime: "時刻を選択", filterOperatorSearchPlaceholder: "検索...", filterOperatorNoResults: "該当なし", filterValuePlaceholder: (props: { field: string }) => `${props.field}を入力`, diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index ec44a814..c9ccb665 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -359,6 +359,56 @@ function PanelDateInput({ ); } +/** + * Single-datetime editor for the panel: the inline `Calendar` up front with a + * labelled time picker beneath it, bridging a local ISO `"YYYY-MM-DDTHH:mm:ss"` + * string. (The chip and the "between" range keep the compact date-picker + time + * box to stay short.) + */ +function PanelDateTimeInput({ + ariaLabel, + value, + onChange, +}: { + ariaLabel: string; + value: string; + onChange: (value: string) => void; +}) { + const t = useDataTableT(); + const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}))?/); + const datePart = match?.[1] ?? ""; + const timePart = match?.[2] ?? ""; + const calValue = /^\d{4}-\d{2}-\d{2}$/.test(datePart) ? parseDate(datePart) : null; + const emit = (nextDate: string, nextTime: string) => { + onChange(nextDate ? `${nextDate}T${nextTime || "00:00"}:00` : ""); + }; + return ( +
+
e.stopPropagation()} + onMouseDownCapture={(e) => e.stopPropagation()} + > + emit(v ? v.toString() : "", timePart)} + /> +
+
+ {t("chooseTime")} + emit(datePart, e.target.value)} + className="astw:h-8 astw:text-sm" + /> +
+
+ ); +} + /** * Draft value editor for the panel's third column, keyed by field + operator. * Holds local draft state and commits via an explicit Apply button (the panel @@ -558,10 +608,10 @@ function PanelValueEditor({ ); } else if (type === "datetime") { - // Single datetime: date-picker + time box (combined into an ISO string). + // Single datetime: inline calendar up front + a labelled time picker below. editor = (
- +
); } else if (isTemporalFilterType(type)) { From ee633bdf4165d37bc61623974974c7de0502da0b Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 21 Jul 2026 19:31:57 +0530 Subject: [PATCH 11/23] feat(data-table): date/datetime "between" uses inline calendar with From/To tabs Replace the two stacked From/To fields in the panel's date and datetime range editors with a single inline calendar and a From/To tab bar on top. Picking a date edits the active tab; each tab shows its current value. datetime tabs also carry the "Choose time" picker. Reuses the single-value inline editors; the chip editor keeps the compact From/To fields. Co-Authored-By: Claude Opus 4.8 --- .../src/components/data-table/toolbar.tsx | 142 ++++++++++++++---- 1 file changed, 110 insertions(+), 32 deletions(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index c9ccb665..8c0e23b9 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -409,6 +409,97 @@ function PanelDateTimeInput({ ); } +/** + * Range editor for date / datetime "between" in the panel: one inline calendar + * (reusing the single-value editors) with a From/To tab bar on top. Picking a + * bound edits whichever tab is active; each tab shows its current value. Keeps + * the range on a single calendar instead of two stacked fields. + */ +function PanelDateRangeInput({ + label, + min, + max, + onChangeMin, + onChangeMax, + withTime = false, +}: { + label: string; + min: string; + max: string; + onChangeMin: (value: string) => void; + onChangeMax: (value: string) => void; + /** datetime range — the active bound also gets a time picker. */ + withTime?: boolean; +}) { + const t = useDataTableT(); + const { locale } = useResolvedLocale(); + const [active, setActive] = useState<"from" | "to">("from"); + + const fmt = (v: string) => { + if (!v) return "—"; + return withTime ? formatDateTimeValue(v, locale) : formatDateValue(v, locale); + }; + const bounds = [ + { key: "from" as const, label: t("filterBetweenFrom"), value: min, onChange: onChangeMin }, + { key: "to" as const, label: t("filterBetweenTo"), value: max, onChange: onChangeMax }, + ]; + const activeBound = bounds.find((b) => b.key === active) ?? bounds[0]; + const error = betweenOrderError( + withTime ? "datetime" : "date", + min, + max, + t("filterBetweenFrom"), + t("filterBetweenTo"), + t, + ); + + return ( +
+ {/* From / To tab bar (each tab shows its picked value) */} +
+ {bounds.map((b) => ( + + ))} +
+ + {/* Inline calendar (+ time) for the active bound */} + {withTime ? ( + + ) : ( + + )} + + {error &&

{error}

} +
+ ); +} + /** * Draft value editor for the panel's third column, keyed by field + operator. * Holds local draft state and commits via an explicit Apply button (the panel @@ -541,43 +632,30 @@ function PanelValueEditor({ ); } else if (isBetween && type === "date") { - // Range dates: two From/To app-shell DatePickers (segmented input + calendar - // icon/popover). A proper range calendar replaces this once the date-picker - // range PR lands. + // Range dates: one inline calendar with From/To tabs on top. editor = ( -
-
- {t("filterBetweenFrom")} - -
-
- {t("filterBetweenTo")} - -
- {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t) && ( -

- {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t)} -

- )} +
+
); } else if (isBetween && type === "datetime") { - // Range datetimes: two From/To date-pickers each paired with a time box. + // Range datetimes: one inline calendar + time with From/To tabs on top. editor = ( -
-
- {t("filterBetweenFrom")} - -
-
- {t("filterBetweenTo")} - -
- {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t) && ( -

- {betweenOrderError(type, min, max, t("filterBetweenFrom"), t("filterBetweenTo"), t)} -

- )} +
+
); } else if (isBetween) { From c2f520e2517ffaa0b8433475560c2e8cbcb6a4c1 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 21 Jul 2026 19:38:36 +0530 Subject: [PATCH 12/23] fix(data-table): taller filter panel so the datetime range time picker fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the fixed panel height (h-96 → h-[32rem]) so the tallest editor — the datetime "between" (From/To tabs + inline calendar + "Choose time") — shows the time picker without clipping. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/components/data-table/toolbar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 8c0e23b9..3dade389 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -243,7 +243,9 @@ function AddFilterPanel({ // panel and its left column never shift under the cursor. The width is // sized so column 3 fits the inline calendar (~290px) even in 3-column // mode (col1 11rem + col2 12rem + ~19.5rem for the value editor). - "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:flex astw:h-96 astw:w-[42.5rem] astw:items-stretch astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md", + // Height fits the tallest editor: the datetime range (From/To tabs + + // inline calendar + "Choose time" picker) without the time being clipped. + "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:flex astw:h-[32rem] astw:w-[42.5rem] astw:items-stretch astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md", "astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:fade-out-0 astw:data-ending-style:zoom-out-95", )} > From 431a416c8f06d3fd6714f669d5b7f929f68ab34e Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 21 Jul 2026 19:40:51 +0530 Subject: [PATCH 13/23] =?UTF-8?q?fix(data-table):=20trim=20filter=20panel?= =?UTF-8?q?=20height=20(h-[32rem]=20=E2=86=92=20h-[28rem])?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 32rem left a large empty gap below the datetime range's time picker; 28rem fits the tallest editor snugly without clipping. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/components/data-table/toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 3dade389..6ab6873f 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -245,7 +245,7 @@ function AddFilterPanel({ // mode (col1 11rem + col2 12rem + ~19.5rem for the value editor). // Height fits the tallest editor: the datetime range (From/To tabs + // inline calendar + "Choose time" picker) without the time being clipped. - "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:flex astw:h-[32rem] astw:w-[42.5rem] astw:items-stretch astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md", + "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:flex astw:h-[28rem] astw:w-[42.5rem] astw:items-stretch astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md", "astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:fade-out-0 astw:data-ending-style:zoom-out-95", )} > From 74b1dc30cc8b885a64e204f9f6a6cad75c67a67d Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 10:21:10 +0530 Subject: [PATCH 14/23] fix(data-table): filter panel always reopens on the first field The panel kept its selected-field state across open/close, so it reopened on whatever field was last picked. Reset to the first field each time it opens. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/components/data-table/toolbar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 6ab6873f..6994f005 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -209,6 +209,12 @@ function AddFilterPanel({ if (col) setOperator(seedPanelOperator(control, col)); }; + // Always reopen on the first field rather than wherever the user last was. + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (next) selectField(columns[0]?.filter.field ?? ""); + }; + const activeFields = new Set(control.filters.map((f) => f.field)); const activeFilter = control.filters.find((f) => f.field === fieldName); let effectiveOperator: FilterOperator | undefined; @@ -217,7 +223,7 @@ function AddFilterPanel({ } return ( - + From f0c5c41baa04a867fa3442f4889af13079f247e3 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 10:31:24 +0530 Subject: [PATCH 15/23] fix(data-table): distinct hover vs selection in panel; demo preset tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Panel field/condition rows: selection is the solid accent (+ bold), hover is a subtle muted tint, applied exclusively so they never look identical (previously both used bg-accent). - Demo: add preset quick-filter tabs (All / Draft / Sent / Overdue) to the left of the toolbar, wired to the status filter — a prototype of a common ERP pattern. Co-Authored-By: Claude Opus 4.8 --- .../vite-app/src/pages/data-table/page.tsx | 42 ++++++++++++++++++- .../src/components/data-table/toolbar.tsx | 17 ++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index 6ff9bddc..3d11c3cd 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -266,6 +266,15 @@ const columns = [ }), ]; +// 🧪 Prototype: preset quick-filter tabs. Each maps to a status filter; "All" +// clears it. A common ERP pattern — shown here to trial the look on the toolbar. +const STATUS_TABS: { key: InvoiceStatus | "all"; label: string }[] = [ + { key: "all", label: "All" }, + { key: "draft", label: "Draft" }, + { key: "sent", label: "Sent" }, + { key: "overdue", label: "Overdue" }, +]; + // ─── Page ────────────────────────────────────────────────────────────────────── const DataTablePage = () => { @@ -294,6 +303,15 @@ const DataTablePage = () => { const table = useDataTable({ columns, data, loading, control }); + // Which preset tab is active, derived from the current status filter. + const statusFilter = control.filters.find((f) => f.field === "status"); + const activeStatusTab = + statusFilter && Array.isArray(statusFilter.value) && statusFilter.value.length === 1 + ? String(statusFilter.value[0]) + : "all"; + const selectStatusTab = (key: string) => + key === "all" ? control.removeFilter("status") : control.addFilter("status", "in", [key]); + return ( @@ -308,7 +326,29 @@ const DataTablePage = () => {
- +
+ {/* 🧪 Preset quick-filter tabs (left) — prototype */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ {/* Chips + Add filter (Add filter stays right-aligned) */} +
+ +
+
diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 6994f005..29874138 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -159,9 +159,13 @@ DataTableFilters.displayName = "DataTable.Filters"; const PANEL_COLUMN_ROW = cn( "astw:flex astw:w-full astw:items-center astw:gap-2 astw:rounded-sm astw:px-2 astw:py-1.5", - "astw:text-left astw:text-sm astw:outline-hidden astw:cursor-default", - "astw:hover:bg-accent astw:hover:text-accent-foreground astw:focus-visible:bg-accent", + "astw:text-left astw:text-sm astw:outline-hidden astw:cursor-default astw:transition-colors", ); +// Distinct row states: hover is a subtle muted tint; selection is the solid accent +// (+ bold). Applied exclusively — hover is only added when the row isn't selected — +// so hovering the selected row doesn't repaint it and the two never look alike. +const PANEL_ROW_HOVER = "astw:hover:bg-muted astw:focus-visible:bg-muted"; +const PANEL_ROW_SELECTED = "astw:bg-accent astw:font-medium astw:text-accent-foreground"; /** * Seed the add-panel's operator for a field: reuse an active filter's operator @@ -267,7 +271,7 @@ function AddFilterPanel({ onClick={() => selectField(col.filter.field)} className={cn( PANEL_COLUMN_ROW, - isSelected && "astw:bg-accent astw:text-accent-foreground", + isSelected ? PANEL_ROW_SELECTED : PANEL_ROW_HOVER, )} > {col.label ?? col.filter.field} @@ -302,7 +306,7 @@ function AddFilterPanel({ onClick={() => setOperator(op)} className={cn( PANEL_COLUMN_ROW, - op === operator && "astw:bg-accent astw:text-accent-foreground", + op === operator ? PANEL_ROW_SELECTED : PANEL_ROW_HOVER, )} > {getOperatorLabel(op, t, config.type)} @@ -628,10 +632,7 @@ function PanelValueEditor({ key={String(v)} type="button" onClick={() => setBoolVal(v)} - className={cn( - PANEL_COLUMN_ROW, - boolVal === v && "astw:bg-accent astw:text-accent-foreground", - )} + className={cn(PANEL_COLUMN_ROW, boolVal === v ? PANEL_ROW_SELECTED : PANEL_ROW_HOVER)} > {v ? t("filterBooleanTrue") : t("filterBooleanFalse")} {boolVal === v && } From 06c20065b32ea1f1fcb6f45fd761d8ae88d2cc0d Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 10:41:38 +0530 Subject: [PATCH 16/23] demo(data-table): stack preset tabs above the filter chips row With the preset tabs present, keep them on their own row and let the active filter chips (+ Add filter) flow onto the row below, instead of crowding them all onto one line. Co-Authored-By: Claude Opus 4.8 --- .../vite-app/src/pages/data-table/page.tsx | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index 3d11c3cd..4ca8af0a 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -326,29 +326,26 @@ const DataTablePage = () => {
-
- {/* 🧪 Preset quick-filter tabs (left) — prototype */} -
- {STATUS_TABS.map((tab) => ( - - ))} -
- {/* Chips + Add filter (Add filter stays right-aligned) */} -
- -
+ {/* 🧪 Row 1 — preset quick-filter tabs (prototype). The toolbar stacks + its children, so active filter chips land on their own row below. */} +
+ {STATUS_TABS.map((tab) => ( + + ))}
+ {/* Row 2 — chips (left) + Add filter (right) */} + From 9e81fdbfae486301ee329261c0ef36dd9b34b1e6 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 10:47:40 +0530 Subject: [PATCH 17/23] feat(data-table): add `slot` prop to DataTable.Filters for split toolbar layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `slot` ("all" default | "chips" | "add") renders the active chips and the Add filter trigger separately, so consumers can place them on different rows — e.g. the trigger in a header row (beside view tabs) with chips on the row below. - Demo: preset tabs + Add filter on the top row, chips on their own row below (only shown when filters are active — no empty row otherwise). - Tests + docs for the new prop; changeset refreshed. Co-Authored-By: Claude Opus 4.8 --- .changeset/data-table-filter-redesign.md | 9 +-- docs/components/data-table.md | 24 ++++++++ .../vite-app/src/pages/data-table/page.tsx | 40 +++++++------ .../components/data-table/toolbar.test.tsx | 31 +++++++++- .../src/components/data-table/toolbar.tsx | 59 +++++++++++++++---- 5 files changed, 126 insertions(+), 37 deletions(-) diff --git a/.changeset/data-table-filter-redesign.md b/.changeset/data-table-filter-redesign.md index eeeee94e..f8332ad0 100644 --- a/.changeset/data-table-filter-redesign.md +++ b/.changeset/data-table-filter-redesign.md @@ -4,7 +4,8 @@ Redesign `DataTable.Filters` for a faster, more direct filtering experience. -- **Add-filter panel**: replaces the old popover + nested selects with a single popover laid out in up to three columns — **field ▸ condition ▸ value**. The condition column appears for fields with more than one operator (number, date/time, string); single-operator fields (enum, uuid) go straight to the value. Values are drafted and committed with an **Apply** button, and the panel stays open so several filters can be added in a row. -- **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-specific editor. -- **Date inputs use app-shell's own components**: single-date operators (exact date / after / before) render the inline `Calendar`; the `is between` range uses two `DatePicker` From/To fields. -- Friendlier operator labels (`is`, `is not`, `is between`, `is any of`, …), multi-select enum values summarized as "N items", compact date ranges, and a consistent primary-colored checkbox across every filter surface. +- **Add-filter panel**: replaces the old popover + nested selects with a single popover laid out in up to three columns — **field ▸ condition ▸ value**. The condition column appears for fields with more than one operator; single-operator fields (enum, uuid) go straight to the value. Values are drafted and committed with an **Apply**/**Update** button, and the panel stays open so several filters can be added in a row. A per-field **Clear** and a sticky **Clear all filters** are built in. +- **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-specific editor. Long values are truncated. +- **Date & time use app-shell's own components**: single date → inline `Calendar`; single datetime → inline calendar + a "Choose time" picker; `date`/`datetime` **between** → one inline calendar with **From / To** tabs; `time` → native time input. (Swaps to the dedicated date-range / datetime pickers 1:1 once those land.) +- Friendlier operator labels (`is`, `is not`, `is between`, `is any of`, …), multi-select enum values summarized as "N items" (localized), an inline error for reversed `between` ranges, and a consistent primary-colored checkbox across every filter surface. +- **New `slot` prop** on `DataTable.Filters` (`"all"` | `"chips"` | `"add"`) to render the chips and the **Add filter** trigger separately for custom toolbar layouts (e.g. trigger in a header row, chips on the row below). diff --git a/docs/components/data-table.md b/docs/components/data-table.md index df685ce8..5b124d74 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -159,6 +159,30 @@ function JournalsPage() { | `children` | `ReactNode` | Sub-components to render inside the root. | | `className` | `string` | Additional CSS class for the root container. | +### `DataTable.Filters` Props + +| Prop | Type | Default | Description | +| ----------- | --------------------------- | ------- | ----------------------------------------------- | +| `slot` | `"all" \| "chips" \| "add"` | `"all"` | Which part to render (see below). | +| `className` | `string` | — | Additional CSS class for the filters container. | + +By default `DataTable.Filters` renders the active filter chips plus the **Add filter** trigger together. The `slot` prop lets you split them across a custom toolbar layout: + +- `"all"` — chips + the **Add filter** trigger (default). +- `"chips"` — only the active chips (renders nothing when there are none). +- `"add"` — only the **Add filter** trigger. + +```tsx +// Add filter in a header row (with tabs, etc.); chips on the row below. + +
+ + +
+ +
+``` + ### `DataTable.Pagination` Props | Prop | Type | Default | Description | diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index 4ca8af0a..0f7c28b8 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -326,26 +326,28 @@ const DataTablePage = () => {
- {/* 🧪 Row 1 — preset quick-filter tabs (prototype). The toolbar stacks - its children, so active filter chips land on their own row below. */} -
- {STATUS_TABS.map((tab) => ( - - ))} + {/* 🧪 Row 1 — preset quick-filter tabs (left, prototype) + Add filter (right) */} +
+
+ {STATUS_TABS.map((tab) => ( + + ))} +
+
- {/* Row 2 — chips (left) + Add filter (right) */} - + {/* Row 2 — active filter chips (only shown when filters are applied) */} + diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index 3c271866..e76b5bed 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -44,15 +44,17 @@ function makeControl(overrides?: Partial): CollectionControl function TestFilters({ control, columns, + slot, }: { control: CollectionControl; columns: Column[]; + slot?: "all" | "chips" | "add"; }) { const table = useDataTable({ columns, data: { rows: [] }, control }); return ( - + ); @@ -249,6 +251,33 @@ describe("DataTable.Filters", () => { ); expect(container.querySelector('[data-slot="data-table-filters"]')).toBeNull(); }); + + it("slot='add' renders only the Add filter trigger (no chips)", () => { + const control = makeControl({ + filters: [{ field: "name", operator: "contains", value: "Alice" }], + }); + render(, { wrapper }); + expect(screen.getByText("Add filter")).toBeDefined(); + expect(document.querySelector('[data-slot="data-table-filter-chip"]')).toBeNull(); + }); + + it("slot='chips' renders only active chips (no Add filter trigger)", () => { + const control = makeControl({ + filters: [{ field: "name", operator: "contains", value: "Alice" }], + }); + render(, { wrapper }); + expect(document.querySelector('[data-slot="data-table-filter-chip"]')).not.toBeNull(); + expect(screen.queryByText("Add filter")).toBeNull(); + }); + + it("slot='chips' renders nothing when there are no active filters", () => { + const control = makeControl({ filters: [] }); + const { container } = render( + , + { wrapper }, + ); + expect(container.querySelector('[data-slot="data-table-filters"]')).toBeNull(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 29874138..1e38eb24 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -108,7 +108,22 @@ type FilterableColumn = Column> & { type AddFilterDraftValue = string | string[]; /** Use `DataTable.Filters` instead of calling this directly. */ -function DataTableFilters({ className }: { className?: string }) { +function DataTableFilters({ + className, + slot = "all", +}: { + className?: string; + /** + * Which part to render, for custom toolbar layouts: + * - `"all"` (default) — active filter chips plus the **Add filter** trigger. + * - `"chips"` — only the active filter chips (renders nothing when there are none). + * - `"add"` — only the **Add filter** trigger. + * + * Render `"add"` and `"chips"` separately to place the trigger and the chips on + * different rows (e.g. the trigger in a header row, chips on the row below). + */ + slot?: "all" | "chips" | "add"; +}) { const ctx = useDataTableContext(); const control = useCollectionControlOptional(); if (!control) { @@ -125,25 +140,43 @@ function DataTableFilters({ className }: { className?: string }) { if (filterableColumns.length === 0) return null; + const chips = filterableColumns + .map((col) => { + const active = control.filters.find((f) => f.field === col.filter.field); + return active ? ( + + ) : null; + }) + .filter(Boolean); + + // The Add filter trigger only. + if (slot === "add") { + return ; + } + + // Active chips only — nothing when there are no active filters. + if (slot === "chips") { + if (chips.length === 0) return null; + return ( +
+ {chips} +
+ ); + } + + // Default: chips (grow to fill) + the right-aligned Add filter trigger. return (
- {/* Active filter chips (grow to fill; wrap as needed) */}
- {filterableColumns.map((col) => { - const active = control.filters.find((f) => f.field === col.filter.field); - if (!active) return null; - return ( - - ); - })} + {chips}
- - {/* Right-aligned action(s): the add-filter panel trigger stays pinned to the - right so it doesn't shift as chips are added (and sits alongside a future - column-settings button). */} + {/* Trigger stays pinned right so it doesn't shift as chips are added. */}
); From d083e4668676e693002e6aa1caaa65c7a9a1209e Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 11:38:05 +0530 Subject: [PATCH 18/23] demo(data-table): add Add-filter placement examples (far-left, after-tabs) Refactor the demo into a reusable InvoiceTable + StatusTabs and show three independent tables trialing where the Add filter button sits relative to the preset tabs: (1) tabs left / Add filter far right (current), (2) Add filter far left, (3) Add filter right after the tabs. Chips still land on the row below. Co-Authored-By: Claude Opus 4.8 --- .../vite-app/src/pages/data-table/page.tsx | 143 ++++++++++++------ 1 file changed, 99 insertions(+), 44 deletions(-) diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index 0f7c28b8..b95fb26e 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Layout, Badge, @@ -6,6 +6,7 @@ import { useDataTable, useCollectionVariables, createColumnHelper, + type CollectionControl, type CollectionVariables, type PageInfo, type DataTableData, @@ -275,9 +276,39 @@ const STATUS_TABS: { key: InvoiceStatus | "all"; label: string }[] = [ { key: "overdue", label: "Overdue" }, ]; -// ─── Page ────────────────────────────────────────────────────────────────────── +// 🧪 Preset status tabs, wired to the collection's `status` filter. "All" clears +// it; each other tab sets `status in [key]`. +function StatusTabs({ control }: { control: CollectionControl }) { + const statusFilter = control.filters.find((f) => f.field === "status"); + const active = + statusFilter && Array.isArray(statusFilter.value) && statusFilter.value.length === 1 + ? String(statusFilter.value[0]) + : "all"; + const select = (key: string) => + key === "all" ? control.removeFilter("status") : control.addFilter("status", "in", [key]); + return ( +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ ); +} -const DataTablePage = () => { +// Reusable invoice table (own control + data). `toolbar` gets the collection +// control so each example can arrange the preset tabs + Add filter differently. +function InvoiceTable({ toolbar }: { toolbar: (control: CollectionControl) => ReactNode }) { const { variables, control } = useCollectionVariables({ params: { pageSize: 10, @@ -303,15 +334,20 @@ const DataTablePage = () => { const table = useDataTable({ columns, data, loading, control }); - // Which preset tab is active, derived from the current status filter. - const statusFilter = control.filters.find((f) => f.field === "status"); - const activeStatusTab = - statusFilter && Array.isArray(statusFilter.value) && statusFilter.value.length === 1 - ? String(statusFilter.value[0]) - : "all"; - const selectStatusTab = (key: string) => - key === "all" ? control.removeFilter("status") : control.addFilter("status", "in", [key]); + return ( + + {toolbar(control)} + + + + + + ); +} +// ─── Page ────────────────────────────────────────────────────────────────────── + +const DataTablePage = () => { return ( @@ -320,40 +356,59 @@ const DataTablePage = () => { Filterable invoice list. Data is supplied by a promise-based stub that takes the collection variables{" "} (filter query, order, cursor - pagination) — a stand-in for the GraphQL query that would normally drive the table. Add a{" "} - Due date filter to pick dates with the inline calendar (or a From/To - range). + pagination) — a stand-in for the GraphQL query that would normally drive the table. The + examples below trial different placements of the Add filter button + alongside the preset tabs (each table is independent). Active chips always land on their + own row below.
- - - {/* 🧪 Row 1 — preset quick-filter tabs (left, prototype) + Add filter (right) */} -
-
- {STATUS_TABS.map((tab) => ( - - ))} -
- -
- {/* Row 2 — active filter chips (only shown when filters are applied) */} - -
- - - - -
+ + {/* Example 1 — tabs left, Add filter far right (current) */} +
+

1 · Tabs left, Add filter far right

+ ( + <> +
+ + +
+ + + )} + /> +
+ + {/* Example 2 — Add filter far left, then tabs */} +
+

2 · Add filter far left

+ ( + <> +
+ + +
+ + + )} + /> +
+ + {/* Example 3 — tabs, then Add filter immediately after them */} +
+

3 · Add filter right after the tabs

+ ( + <> +
+ + +
+ + + )} + /> +
); From 7e2197f104c437fcec572ee56305fb760b0d226b Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 11:43:58 +0530 Subject: [PATCH 19/23] feat(data-table): add `addIconOnly` prop for an icon-only Add filter trigger `addIconOnly` renders the Add filter trigger as just the funnel icon; the "Add filter" label is dropped from view but kept as the button's aria-label. - Demo: add a 4th example (Add filter far left, icon only). - Test + docs for the new prop. Co-Authored-By: Claude Opus 4.8 --- docs/components/data-table.md | 9 +++++---- examples/vite-app/src/pages/data-table/page.tsx | 16 ++++++++++++++++ .../src/components/data-table/toolbar.test.tsx | 14 +++++++++++++- .../core/src/components/data-table/toolbar.tsx | 14 ++++++++++---- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 5b124d74..8d9c1823 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -161,10 +161,11 @@ function JournalsPage() { ### `DataTable.Filters` Props -| Prop | Type | Default | Description | -| ----------- | --------------------------- | ------- | ----------------------------------------------- | -| `slot` | `"all" \| "chips" \| "add"` | `"all"` | Which part to render (see below). | -| `className` | `string` | — | Additional CSS class for the filters container. | +| Prop | Type | Default | Description | +| ------------- | --------------------------- | ------- | ---------------------------------------------------------------------------------------------- | +| `slot` | `"all" \| "chips" \| "add"` | `"all"` | Which part to render (see below). | +| `addIconOnly` | `boolean` | `false` | Render the **Add filter** trigger as an icon-only button (the label becomes its `aria-label`). | +| `className` | `string` | — | Additional CSS class for the filters container. | By default `DataTable.Filters` renders the active filter chips plus the **Add filter** trigger together. The `slot` prop lets you split them across a custom toolbar layout: diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index b95fb26e..996b4a87 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -409,6 +409,22 @@ const DataTablePage = () => { )} /> + + {/* Example 4 — Add filter far left, icon-only (no label) */} +
+

4 · Add filter far left (icon only)

+ ( + <> +
+ + +
+ + + )} + /> +
); diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index e76b5bed..c90ea36b 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -45,16 +45,18 @@ function TestFilters({ control, columns, slot, + addIconOnly, }: { control: CollectionControl; columns: Column[]; slot?: "all" | "chips" | "add"; + addIconOnly?: boolean; }) { const table = useDataTable({ columns, data: { rows: [] }, control }); return ( - + ); @@ -278,6 +280,16 @@ describe("DataTable.Filters", () => { ); expect(container.querySelector('[data-slot="data-table-filters"]')).toBeNull(); }); + + it("addIconOnly renders an icon-only trigger (label kept as aria-label)", () => { + const control = makeControl({ filters: [] }); + render(, { + wrapper, + }); + // Reachable by its accessible name, but the label text is not rendered. + const trigger = screen.getByRole("button", { name: "Add filter" }); + expect(trigger.textContent).toBe(""); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 1e38eb24..772b926d 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -111,6 +111,7 @@ type AddFilterDraftValue = string | string[]; function DataTableFilters({ className, slot = "all", + addIconOnly = false, }: { className?: string; /** @@ -123,6 +124,8 @@ function DataTableFilters({ * different rows (e.g. the trigger in a header row, chips on the row below). */ slot?: "all" | "chips" | "add"; + /** Render the **Add filter** trigger as an icon-only button (label → `aria-label`). */ + addIconOnly?: boolean; }) { const ctx = useDataTableContext(); const control = useCollectionControlOptional(); @@ -151,7 +154,7 @@ function DataTableFilters({ // The Add filter trigger only. if (slot === "add") { - return ; + return ; } // Active chips only — nothing when there are no active filters. @@ -177,7 +180,7 @@ function DataTableFilters({ {chips} {/* Trigger stays pinned right so it doesn't shift as chips are added. */} - + ); } @@ -221,9 +224,12 @@ function seedPanelOperator( function AddFilterPanel({ columns, control, + iconOnly = false, }: { columns: FilterableColumn[]; control: CollectionControl; + /** Render the trigger as an icon-only button (label kept as `aria-label`). */ + iconOnly?: boolean; }) { const t = useDataTableT(); const [open, setOpen] = useState(false); @@ -263,9 +269,9 @@ function AddFilterPanel({ + } /> From 3b04d74320f20b0f736099644d27e760f4240d59 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 12:59:10 +0530 Subject: [PATCH 20/23] demo(data-table): consolidate to the icon-only Add filter layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settle on the icon-only Add filter on the far left (label as aria-label) and drop the other placement experiments. Show two examples — with preset tabs and without — both with chips on the row below. Bump the icon-only funnel to 14px (size-3.5) so it has proper presence in the button; stroke stays the default. Co-Authored-By: Claude Opus 4.8 --- .../vite-app/src/pages/data-table/page.tsx | 55 ++++--------------- .../src/components/data-table/toolbar.tsx | 4 +- 2 files changed, 13 insertions(+), 46 deletions(-) diff --git a/examples/vite-app/src/pages/data-table/page.tsx b/examples/vite-app/src/pages/data-table/page.tsx index 996b4a87..133bc704 100644 --- a/examples/vite-app/src/pages/data-table/page.tsx +++ b/examples/vite-app/src/pages/data-table/page.tsx @@ -357,52 +357,20 @@ const DataTablePage = () => { takes the collection variables{" "} (filter query, order, cursor pagination) — a stand-in for the GraphQL query that would normally drive the table. The - examples below trial different placements of the Add filter button - alongside the preset tabs (each table is independent). Active chips always land on their - own row below. + toolbar uses an icon-only Add filter button on the far left; active chips + land on their own row below. - {/* Example 1 — tabs left, Add filter far right (current) */} + {/* With preset tabs */}
-

1 · Tabs left, Add filter far right

- ( - <> -
- - -
- - - )} - /> -
- - {/* Example 2 — Add filter far left, then tabs */} -
-

2 · Add filter far left

- ( - <> -
- - -
- - - )} - /> -
- - {/* Example 3 — tabs, then Add filter immediately after them */} -
-

3 · Add filter right after the tabs

+

With preset tabs

( <> + {/* gap-2 matches the toolbar's p-2 so the icon sits an even step from the tabs */}
+ -
@@ -410,16 +378,13 @@ const DataTablePage = () => { />
- {/* Example 4 — Add filter far left, icon-only (no label) */} + {/* Without tabs */}
-

4 · Add filter far left (icon only)

+

Without tabs

( + toolbar={() => ( <> -
- - -
+ )} diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 772b926d..9f7afb92 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -270,7 +270,9 @@ function AddFilterPanel({ - + {/* Icon-only reads better a touch larger (matching icon-button convention); + inline-with-label stays smaller so it sits neatly beside the text. */} + {!iconOnly && t("addFilter")} } From 17fca4ff96458a11d9e8bb938d1ca3e6452d2e00 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 13:02:16 +0530 Subject: [PATCH 21/23] fix(data-table): keep slot="add" trigger at natural width in a column toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataTable.Toolbar is flex-col, which stretches its children — so a standalone `` rendered the Add filter button full-width. Wrap the trigger in a shrink-to-content box so it keeps its natural size wherever it's placed. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/components/data-table/toolbar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 9f7afb92..48d1671d 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -152,9 +152,15 @@ function DataTableFilters({ }) .filter(Boolean); - // The Add filter trigger only. + // The Add filter trigger only. Wrapped in a shrink-to-content box so the button + // keeps its natural width instead of stretching to fill a column-flex toolbar + // (DataTable.Toolbar is `flex-col`, which stretches its children by default). if (slot === "add") { - return ; + return ( +
+ +
+ ); } // Active chips only — nothing when there are no active filters. From 30242b971f95c9ac6ef838524bd6749619f27669 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 22 Jul 2026 13:07:31 +0530 Subject: [PATCH 22/23] docs(data-table): TODO to swap temporal filter stopgaps for real pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a TODO noting the time / datetime / datetime-range filter editors are stopgaps to be replaced 1:1 with dedicated app-shell pickers (TimePicker, DateTimePicker, Date/DateTimeRangePicker) once available — the ISO value shapes already match, so each swap is UI-only. Co-Authored-By: Claude Opus 4.8 --- .../core/src/components/data-table/toolbar.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 48d1671d..3c1009d5 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -418,6 +418,20 @@ function PanelDateInput({ ); } +// ----------------------------------------------------------------------------- +// TODO(app-shell): replace the temporal filter editors below with dedicated +// pickers once app-shell ships them: +// - `time` → a proper TimePicker (currently a native ). +// - `datetime` → a DateTimePicker (currently a date `DatePicker` + a native +// time box, combined into an ISO string — see PanelDateTimeInput +// and DateTimeFilterInput). +// - `datetime`/`date` "between" → a DateRangePicker / DateTimeRangePicker +// (currently one inline calendar with From/To tabs, see +// PanelDateRangeInput; the chip editor uses two From/To fields). +// These stopgaps intentionally emit the same ISO value shapes, so each swap is +// UI-only — no change to the committed filter values. +// ----------------------------------------------------------------------------- + /** * Single-datetime editor for the panel: the inline `Calendar` up front with a * labelled time picker beneath it, bridging a local ISO `"YYYY-MM-DDTHH:mm:ss"` From 34a1a3c32b0fd55552934b9f6f62fa8803c106a5 Mon Sep 17 00:00:00 2001 From: itsprade Date: Thu, 23 Jul 2026 14:18:28 +0530 Subject: [PATCH 23/23] =?UTF-8?q?chore(data-table):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20untrack=20.claude/launch.json,=20simplify=20enum=20?= =?UTF-8?q?count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the local Claude Code preview config (.claude/launch.json) from the repo and gitignore .claude/ (matches the existing .cursor/ ignore). - Drop the English pluralization helper; the multi-select enum chip summary now uses a simple "(s)" suffix ("2 Status(s)") instead of owning plural rules. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 14 -------------- .gitignore | 1 + packages/core/src/components/data-table/i18n.ts | 14 +++----------- .../src/components/data-table/toolbar.test.tsx | 4 ++-- .../core/src/components/data-table/toolbar.tsx | 2 +- 5 files changed, 7 insertions(+), 28 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 06dad8b8..00000000 --- a/.claude/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "vite-app", - "runtimeExecutable": "bash", - "runtimeArgs": [ - "-c", - "cd examples/vite-app && npm_config_engine_strict=false npx vite --port $PORT" - ], - "autoPort": true - } - ] -} diff --git a/.gitignore b/.gitignore index 1c02077b..0139f6eb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist *.tsbuildinfo .DS_Store .cursor/ +.claude/ .pnpm-store diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index a969e00d..3a508f7c 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -1,13 +1,5 @@ import { defineI18nLabels } from "@/hooks/i18n"; -/** English plural of a column label for enum count summaries ("Status" → "statuses"). */ -function pluralizeEn(label: string): string { - const w = label.toLowerCase(); - if (/(s|x|z|ch|sh)$/.test(w)) return `${w}es`; - if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ies`; - return `${w}s`; -} - export const dataTableLabels = defineI18nLabels({ en: { // DataTable.Body @@ -61,9 +53,9 @@ export const dataTableLabels = defineI18nLabels({ filterOperatorSearchPlaceholder: "Search...", filterOperatorNoResults: "No results", filterValuePlaceholder: (props: { field: string }) => `Enter ${props.field.toLowerCase()}`, - // Multi-select enum chip summary, e.g. "2 statuses". - filterEnumCount: (props: { count: number; noun: string }) => - `${props.count} ${pluralizeEn(props.noun)}`, + // Multi-select enum chip summary, e.g. "2 Status(s)". Uses a simple "(s)" + // suffix rather than owning English pluralization rules. + filterEnumCount: (props: { count: number; noun: string }) => `${props.count} ${props.noun}(s)`, filterBooleanTrue: "True", filterBooleanFalse: "False", filterOperator_eq: "is", diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index c90ea36b..ca0d8e42 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -869,8 +869,8 @@ describe("EnumFilterEditor", () => { // enum has a single operator, so it renders as plain text, not a button. expect(screen.getByText("is any of")).toBeDefined(); - // >1 option selected is summarized as "N ". - expect(screen.getByText("2 statuses")).toBeDefined(); + // >1 option selected is summarized as "N