diff --git a/.changeset/data-table-filter-redesign.md b/.changeset/data-table-filter-redesign.md
new file mode 100644
index 00000000..f8332ad0
--- /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 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/.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/docs/components/data-table.md b/docs/components/data-table.md
index d17c7e59..bce581c5 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). Optional. Pass `columnSettings` to render the built-in "Columns" control (show/hide + reorder + pin) at the top-right. See props below. |
-| `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`. |
@@ -167,6 +167,31 @@ function JournalsPage() {
| `columnSettings` | `boolean` | `false` | Render the built-in "Columns" control (show/hide + reorder + pin) anchored to the top-right. Persists per-user when `useDataTable` has a `tableId`. |
| `className` | `string` | — | Additional CSS class for the toolbar container. |
+### `DataTable.Filters` Props
+
+| 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:
+
+- `"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 |
@@ -506,7 +531,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 |
| --------- | ---------------- | ------------------------------------------------------------ |
@@ -514,24 +539,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 1febf870..133bc704 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,
@@ -20,13 +21,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 +59,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 +190,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 +207,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,18 +234,81 @@ 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`)),
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" },
}),
+ // 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 ──────────────────────────────────────────────────────────────────────
+// 🧪 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" },
+];
-const DataTablePage = () => {
+// 🧪 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) => (
+ select(tab.key)}
+ className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
+ active === tab.key
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
+ }`}
+ >
+ {tab.label}
+
+ ))}
+
+ );
+}
+
+// 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,
@@ -238,6 +334,20 @@ const DataTablePage = () => {
const table = useDataTable({ columns, data, loading, control });
+ return (
+
+ {toolbar(control)}
+
+
+
+
+
+ );
+}
+
+// ─── Page ──────────────────────────────────────────────────────────────────────
+
+const DataTablePage = () => {
return (
@@ -246,18 +356,40 @@ 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 use the DatePicker as the input.
+ pagination) — a stand-in for the GraphQL query that would normally drive the table. The
+ toolbar uses an icon-only Add filter button on the far left; active chips
+ land on their own row below.
-
-
-
-
-
-
-
-
-
+
+ {/* With preset tabs */}
+
+ With preset tabs
+ (
+ <>
+ {/* gap-2 matches the toolbar's p-2 so the icon sits an even step from the tabs */}
+
+
+
+
+
+ >
+ )}
+ />
+
+
+ {/* Without tabs */}
+
+ Without tabs
+ (
+ <>
+
+
+ >
+ )}
+ />
+
);
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/i18n.ts b/packages/core/src/components/data-table/i18n.ts
index 4249aa94..3a508f7c 100644
--- a/packages/core/src/components/data-table/i18n.ts
+++ b/packages/core/src/components/data-table/i18n.ts
@@ -45,11 +45,21 @@ export const dataTableLabels = defineI18nLabels({
// Filters
addFilter: "Add filter",
applyFilter: "Apply",
+ updateFilter: "Update",
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()}`,
+ // 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: "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",
@@ -60,9 +70,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",
@@ -72,6 +82,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
@@ -116,7 +128,16 @@ export const dataTableLabels = defineI18nLabels({
// Filters
addFilter: "フィルタを追加",
applyFilter: "適用",
+ updateFilter: "更新",
removeFilter: "フィルタを削除",
+ clearFilter: "このフィルタをクリア",
+ clearAllFilters: "すべてのフィルタをクリア",
+ chooseTime: "時刻を選択",
+ 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: "と等しい",
@@ -141,6 +162,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 9ac83509..ca0d8e42 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";
@@ -44,15 +44,19 @@ function makeControl(overrides?: Partial): CollectionControl
function TestFilters({
control,
columns,
+ slot,
+ addIconOnly,
}: {
control: CollectionControl;
columns: Column[];
+ slot?: "all" | "chips" | "add";
+ addIconOnly?: boolean;
}) {
const table = useDataTable({ columns, data: { rows: [] }, control });
return (
-
+
);
@@ -60,6 +64,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 when the field has >1 operator, otherwise a
+// - value → always a ; clicking it opens the value-editor popover
+// - remove → a with aria-label "Remove filter"
+// The value segment is therefore always the *second-to-last* button, regardless
+// of whether the operator segment is a button — a stable way to open the value
+// editor without depending on the (type-specific, now value-only) button name.
+
+function valueSegmentButton(chipIndex = 0): HTMLButtonElement {
+ const chip = document.querySelectorAll('[data-slot="data-table-filter-chip"]')[chipIndex];
+ if (!chip) throw new Error("No filter chip rendered");
+ const buttons = chip.querySelectorAll("button");
+ return buttons[buttons.length - 2] as HTMLButtonElement;
+}
+
+async function openValueEditor(user: ReturnType, chipIndex = 0) {
+ await user.click(valueSegmentButton(chipIndex));
+}
+
+// ---------------------------------------------------------------------------
+// Date filter (DatePicker) helpers
+// ---------------------------------------------------------------------------
+// `date` filters render the app-shell DatePicker — a labelled group of
+// `spinbutton` segments — instead of a native date input. These helpers drive
+// it via the segments, re-querying by index so they survive controlled
+// re-renders.
+
+const datePickerGroup = (index: number) => screen.getAllByRole("group")[index];
+
+async function typeDateInto(
+ user: ReturnType,
+ groupIndex: number,
+ iso: string,
+) {
+ const [year, month, day] = iso.split("-");
+ const seg = (name: string) =>
+ within(datePickerGroup(groupIndex)).getByRole("spinbutton", { name });
+ await user.click(seg("month"));
+ await user.keyboard(month);
+ await user.click(seg("day"));
+ await user.keyboard(day);
+ await user.click(seg("year"));
+ await user.keyboard(year);
+}
+
+async function clearDateIn(user: ReturnType, groupIndex: number) {
+ // Clear every segment. Leaving the day set would trigger the field's on-blur
+ // backfill (assume current month/year), so a genuine "cleared" state must
+ // remove the day too — the day is the trigger for that backfill.
+ for (const name of ["day", "month", "year"]) {
+ await user.click(within(datePickerGroup(groupIndex)).getByRole("spinbutton", { name }));
+ await user.keyboard("{Delete}");
+ }
+}
+
// ---------------------------------------------------------------------------
// Column fixtures
// ---------------------------------------------------------------------------
@@ -127,42 +191,6 @@ const booleanColumn: Column = {
filter: { type: "boolean", field: "enabled" },
};
-// ---------------------------------------------------------------------------
-// Date filter (DatePicker) helpers
-// ---------------------------------------------------------------------------
-// `date` filters render the app-shell DatePicker — a labelled group of
-// `spinbutton` segments — instead of a native date input. These helpers drive
-// it via the segments, re-querying by index so they survive controlled
-// re-renders.
-
-const datePickerGroup = (index: number) => screen.getAllByRole("group")[index];
-
-async function typeDateInto(
- user: ReturnType,
- groupIndex: number,
- iso: string,
-) {
- const [year, month, day] = iso.split("-");
- const seg = (name: string) =>
- within(datePickerGroup(groupIndex)).getByRole("spinbutton", { name });
- await user.click(seg("month"));
- await user.keyboard(month);
- await user.click(seg("day"));
- await user.keyboard(day);
- await user.click(seg("year"));
- await user.keyboard(year);
-}
-
-async function clearDateIn(user: ReturnType, groupIndex: number) {
- // Clear every segment. Leaving the day set would trigger the field's on-blur
- // backfill (assume current month/year), so a genuine "cleared" state must
- // remove the day too — the day is the trigger for that backfill.
- for (const name of ["day", "month", "year"]) {
- await user.click(within(datePickerGroup(groupIndex)).getByRole("spinbutton", { name }));
- await user.keyboard("{Delete}");
- }
-}
-
// ---------------------------------------------------------------------------
// DataTable.Filters — rendering
// ---------------------------------------------------------------------------
@@ -176,7 +204,7 @@ describe("DataTable.Filters", () => {
expect(container.querySelector('[data-slot="data-table-filter-chip"]')).toBeNull();
});
- it("renders a filter chip for each active filter", () => {
+ it("renders a segmented filter chip (field / operator / value) for each active filter", () => {
const control = makeControl({
filters: [{ field: "name", operator: "contains", value: "Alice" }],
});
@@ -184,7 +212,11 @@ describe("DataTable.Filters", () => {
wrapper,
});
expect(container.querySelector('[data-slot="data-table-filter-chip"]')).not.toBeNull();
- expect(screen.getByText(/Name contains Alice/)).toBeDefined();
+ // The chip is segmented: the field, operator, and value each render in their
+ // own element rather than a single combined label.
+ expect(screen.getByText("Name")).toBeDefined();
+ expect(screen.getByText("contains")).toBeDefined();
+ expect(screen.getByText("Alice")).toBeDefined();
});
it("renders the add filter button when there are unfiltered filterable columns", () => {
@@ -195,14 +227,17 @@ describe("DataTable.Filters", () => {
expect(screen.getByText("Add filter")).toBeDefined();
});
- it("does not render the add filter button when all filterable columns are active", () => {
+ it("still renders the add filter button when all filterable columns are active", () => {
+ // The add-filter trigger is always available now (it re-opens the editor for
+ // active fields too), so it must remain present even when every filterable
+ // column already has a chip.
const control = makeControl({
filters: [{ field: "name", operator: "contains", value: "Alice" }],
});
render( , {
wrapper,
});
- expect(screen.queryByText("Add filter")).toBeNull();
+ expect(screen.getByText("Add filter")).toBeDefined();
});
it("returns null when there are no filterable columns", () => {
@@ -218,6 +253,142 @@ 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();
+ });
+
+ 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("");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// AddFilterPanel — the add-filter trigger opens a 3-column panel
+// ---------------------------------------------------------------------------
+
+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( , {
+ wrapper,
+ });
+
+ await user.click(screen.getByRole("button", { name: /Add filter/ }));
+
+ 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();
+ });
+
+ 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 applying 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/ }));
+ // 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 });
+ });
+
+ 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: /^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");
+ });
});
// ---------------------------------------------------------------------------
@@ -245,7 +416,7 @@ describe("FilterChip", () => {
// ---------------------------------------------------------------------------
describe("StringFilterEditor", () => {
- it("shows an Apply button after opening the chip popover", async () => {
+ it("shows an Apply button after opening the chip value popover", async () => {
const user = userEvent.setup();
const control = makeControl({
filters: [{ field: "name", operator: "contains", value: "Alice" }],
@@ -254,7 +425,7 @@ describe("StringFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+ await openValueEditor(user);
expect(await screen.findByRole("button", { name: "Apply" })).toBeDefined();
});
@@ -268,13 +439,15 @@ describe("StringFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+ await openValueEditor(user);
const input = await screen.findByRole("textbox");
await user.clear(input);
await user.type(input, "Bob");
await user.click(screen.getByRole("button", { name: "Apply" }));
+ // The operator comes from the chip's own operator segment (the editor is
+ // rendered with hideOperator), so it stays "contains".
expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Bob", {
caseSensitive: false,
});
@@ -289,7 +462,7 @@ describe("StringFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+ await openValueEditor(user);
const input = await screen.findByRole("textbox");
await user.clear(input);
@@ -310,7 +483,7 @@ describe("StringFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+ await openValueEditor(user);
const input = await screen.findByRole("textbox");
await user.clear(input);
@@ -328,7 +501,7 @@ describe("StringFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+ await openValueEditor(user);
expect(await screen.findByText("Case sensitive")).toBeDefined();
});
@@ -342,7 +515,7 @@ describe("StringFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+ await openValueEditor(user);
const checkbox = await screen.findByRole("checkbox");
await user.click(checkbox);
@@ -370,7 +543,7 @@ describe("StringFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+ await openValueEditor(user);
const checkbox = await screen.findByRole("checkbox");
expect((checkbox as HTMLElement).dataset.checked).toBeDefined();
@@ -382,7 +555,7 @@ describe("StringFilterEditor", () => {
// ---------------------------------------------------------------------------
describe("UuidFilterEditor", () => {
- it("shows an Apply button after opening the chip popover", async () => {
+ it("shows an Apply button after opening the chip value popover", async () => {
const user = userEvent.setup();
const control = makeControl({
filters: [{ field: "id", operator: "eq", value: "uuid-123" }],
@@ -391,7 +564,7 @@ describe("UuidFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /ID equals uuid-123/ }));
+ await openValueEditor(user);
expect(await screen.findByRole("button", { name: "Apply" })).toBeDefined();
});
@@ -405,7 +578,7 @@ describe("UuidFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /ID equals uuid-123/ }));
+ await openValueEditor(user);
const input = await screen.findByRole("textbox");
await user.clear(input);
@@ -424,7 +597,7 @@ describe("UuidFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /ID equals uuid-123/ }));
+ await openValueEditor(user);
const input = await screen.findByRole("textbox");
await user.clear(input);
@@ -440,7 +613,7 @@ describe("UuidFilterEditor", () => {
// ---------------------------------------------------------------------------
describe("NumericFilterEditor", () => {
- it("shows an Apply button after opening the chip popover", async () => {
+ it("shows an Apply button after opening the chip value popover", async () => {
const user = userEvent.setup();
const control = makeControl({
filters: [{ field: "count", operator: "eq", value: 42 }],
@@ -449,7 +622,7 @@ describe("NumericFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Count equals 42/ }));
+ await openValueEditor(user);
expect(await screen.findByRole("button", { name: "Apply" })).toBeDefined();
});
@@ -463,7 +636,7 @@ describe("NumericFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Count equals 42/ }));
+ await openValueEditor(user);
const input = await screen.findByRole("spinbutton");
await user.clear(input);
@@ -482,7 +655,7 @@ describe("NumericFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Count equals 42/ }));
+ await openValueEditor(user);
const input = await screen.findByRole("spinbutton");
await user.clear(input);
@@ -498,7 +671,7 @@ describe("NumericFilterEditor", () => {
// ---------------------------------------------------------------------------
describe("DateFilterEditor", () => {
- it("shows an Apply button after opening the chip popover", async () => {
+ it("shows an Apply button after opening the chip value popover", async () => {
const user = userEvent.setup();
const control = makeControl({
filters: [{ field: "createdAt", operator: "eq", value: "2025-01-01" }],
@@ -507,7 +680,7 @@ describe("DateFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Created At exact date/ }));
+ await openValueEditor(user);
expect(await screen.findByRole("button", { name: "Apply" })).toBeDefined();
});
@@ -519,7 +692,7 @@ describe("DateFilterEditor", () => {
});
render( , { wrapper });
- await user.click(screen.getByRole("button", { name: /Created At exact date/ }));
+ await openValueEditor(user);
// The editor uses the app-shell DatePicker: a labelled group of spinbuttons.
expect(
@@ -534,7 +707,7 @@ describe("DateFilterEditor", () => {
});
render( , { wrapper });
- await user.click(screen.getByRole("button", { name: /Created At exact date/ }));
+ await openValueEditor(user);
await screen.findByRole("button", { name: "Apply" });
await typeDateInto(user, 0, "2026-06-15");
@@ -550,7 +723,7 @@ describe("DateFilterEditor", () => {
});
render( , { wrapper });
- await user.click(screen.getByRole("button", { name: /Created At exact date/ }));
+ await openValueEditor(user);
await screen.findByRole("button", { name: "Apply" });
await clearDateIn(user, 0);
@@ -559,43 +732,44 @@ describe("DateFilterEditor", () => {
expect(control.removeFilter).toHaveBeenCalledWith("createdAt");
});
- it("labels date operators as exact date / after / before", () => {
- const control = makeControl({
- filters: [
- { field: "createdAt", operator: "eq", value: "2025-01-01" },
- { field: "createdAt", operator: "gte", value: "2025-02-01" },
- { field: "createdAt", operator: "lte", value: "2025-03-01" },
- ],
- });
- // Render each via its own chip; assert the friendly date labels appear and
- // the numeric labels do not.
+ it("labels date operators as exact date / after / before and formats the value", () => {
+ // The chip's operator segment shows the friendly date labels, and the value
+ // segment shows a locale-formatted medium date (never the raw ISO string).
const { rerender } = render(
,
{ wrapper },
);
- // Operator label is friendly, and the value is locale-formatted (not raw ISO).
- expect(screen.getByText(/Created At exact date Jan 1, 2025/)).toBeDefined();
+ expect(screen.getByText("exact date")).toBeDefined();
+ expect(screen.getByText("Jan 1, 2025")).toBeDefined();
expect(screen.queryByText(/2025-01-01/)).toBeNull();
rerender(
,
);
- expect(screen.getByText(/Created At after Feb 1, 2025/)).toBeDefined();
- expect(screen.queryByText(/greater than/)).toBeNull();
+ expect(screen.getByText("after")).toBeDefined();
+ expect(screen.getByText("Feb 1, 2025")).toBeDefined();
+ expect(screen.queryByText("greater than")).toBeNull();
rerender(
,
);
- expect(screen.getByText(/Created At before Mar 1, 2025/)).toBeDefined();
+ expect(screen.getByText("before")).toBeDefined();
+ expect(screen.getByText("Mar 1, 2025")).toBeDefined();
});
it("preserves a legacy operator (e.g. gt) on Apply instead of coercing to eq", async () => {
@@ -608,12 +782,15 @@ describe("DateFilterEditor", () => {
});
render( , { wrapper });
- // The chip still renders the legacy operator's (generic) label.
- await user.click(screen.getByRole("button", { name: /Created At greater than/ }));
- await screen.findByRole("button", { name: "Apply" });
+ // The chip's operator segment still renders the legacy operator's (generic) label.
+ expect(screen.getByText("greater than")).toBeDefined();
- // Apply without touching the operator → the operator is preserved.
+ // Open the value editor (operator hidden there) and Apply without touching
+ // anything → the operator is preserved.
+ await openValueEditor(user);
+ await screen.findByRole("button", { name: "Apply" });
await user.click(screen.getByRole("button", { name: "Apply" }));
+
expect(control.addFilter).toHaveBeenCalledWith("createdAt", "gt", "2025-01-01");
expect(control.addFilter).not.toHaveBeenCalledWith("createdAt", "eq", expect.anything());
});
@@ -624,7 +801,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" }],
@@ -633,25 +810,15 @@ describe("TemporalFilterEditor", () => {
wrapper,
});
- await user.click(
- screen.getByRole("button", {
- name: /Published At equals 2025-01-01T10:30:00Z/,
- }),
- );
+ 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" }],
@@ -660,19 +827,13 @@ describe("TemporalFilterEditor", () => {
wrapper,
});
- await user.click(
- screen.getByRole("button", {
- name: /Published At equals 2025-01-01T10:30:00Z/,
- }),
- );
+ 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 () => {
@@ -684,7 +845,7 @@ describe("TemporalFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Opens At equals 09:30/ }));
+ await openValueEditor(user);
const input = await screen.findByDisplayValue("09:30");
await user.clear(input);
@@ -700,6 +861,18 @@ describe("TemporalFilterEditor", () => {
// ---------------------------------------------------------------------------
describe("EnumFilterEditor", () => {
+ it("shows the operator segment as 'is any of' and summarizes multiple selections", () => {
+ const control = makeControl({
+ filters: [{ field: "status", operator: "in", value: ["active", "inactive"] }],
+ });
+ render( , { wrapper });
+
+ // 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 (s)".
+ expect(screen.getByText("2 Status(s)")).toBeDefined();
+ });
+
it("toggling a checkbox calls addFilter immediately without an Apply button", async () => {
const user = userEvent.setup();
const control = makeControl({
@@ -709,9 +882,9 @@ describe("EnumFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Status in: Active/ }));
+ await openValueEditor(user);
- // Click the "Inactive" option
+ // Click the "Inactive" option (only appears in the opened value list).
await user.click(await screen.findByText("Inactive"));
expect(control.addFilter).toHaveBeenCalledWith("status", "in", ["active", "inactive"]);
@@ -728,21 +901,24 @@ describe("EnumFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Status in: Active/ }));
+ await openValueEditor(user);
- // Uncheck "Active" (the currently selected option)
- await user.click(await screen.findByText("Active"));
+ // Wait for the value list to open, then uncheck "Active" from within it.
+ // ("Active" also appears in the chip's value segment, so scope to the list.)
+ await screen.findByText("Inactive");
+ const list = document.querySelector('[data-slot="data-table-filter-enum"]') as HTMLElement;
+ await user.click(within(list).getByText("Active"));
expect(control.removeFilter).toHaveBeenCalledWith("status");
});
});
// ---------------------------------------------------------------------------
-// BooleanFilterEditor — operator selector + value select + Apply button
+// BooleanFilterEditor — value select + Apply button (operator lives on the chip)
// ---------------------------------------------------------------------------
describe("BooleanFilterEditor", () => {
- it("shows an Apply button after opening the chip popover", async () => {
+ it("shows an Apply button after opening the chip value popover", async () => {
const user = userEvent.setup();
const control = makeControl({
filters: [{ field: "enabled", operator: "eq", value: true }],
@@ -751,7 +927,11 @@ describe("BooleanFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Enabled equals True/ }));
+ // Operator segment reflects the "is" (eq) operator; value segment shows "True".
+ expect(screen.getByText("is")).toBeDefined();
+ expect(screen.getByText("True")).toBeDefined();
+
+ await openValueEditor(user);
expect(await screen.findByRole("button", { name: "Apply" })).toBeDefined();
});
@@ -765,7 +945,7 @@ describe("BooleanFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Enabled equals True/ }));
+ await openValueEditor(user);
await screen.findByRole("button", { name: "Apply" });
expect(control.addFilter).not.toHaveBeenCalled();
@@ -780,7 +960,7 @@ describe("BooleanFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Enabled equals True/ }));
+ await openValueEditor(user);
await user.click(await screen.findByRole("button", { name: "Apply" }));
expect(control.addFilter).toHaveBeenCalledWith("enabled", "eq", true);
@@ -795,7 +975,7 @@ describe("BooleanFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Enabled equals False/ }));
+ await openValueEditor(user);
await user.click(await screen.findByRole("button", { name: "Apply" }));
expect(control.addFilter).toHaveBeenCalledWith("enabled", "eq", false);
@@ -810,7 +990,11 @@ describe("BooleanFilterEditor", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Enabled not equals True/ }));
+ // Operator segment reflects "is not" (ne); the hidden editor operator is
+ // seeded from the filter's operator, so Apply preserves ne.
+ expect(screen.getByText("is not")).toBeDefined();
+
+ await openValueEditor(user);
await user.click(await screen.findByRole("button", { name: "Apply" }));
expect(control.addFilter).toHaveBeenCalledWith("enabled", "ne", true);
@@ -822,7 +1006,7 @@ describe("BooleanFilterEditor", () => {
// ---------------------------------------------------------------------------
describe("NumericFilterEditor (between)", () => {
- it("shows two number inputs when filter operator is between", async () => {
+ it("shows the between value summary and two number inputs when operator is between", async () => {
const user = userEvent.setup();
const control = makeControl({
filters: [{ field: "count", operator: "between", value: { min: 10, max: 50 } }],
@@ -831,7 +1015,11 @@ describe("NumericFilterEditor (between)", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ }));
+ // Chip: operator segment "is between", value segment "10 - 50".
+ expect(screen.getByText("is between")).toBeDefined();
+ expect(screen.getByText("10 - 50")).toBeDefined();
+
+ await openValueEditor(user);
const inputs = await screen.findAllByRole("spinbutton");
expect(inputs.length).toBe(2);
@@ -848,7 +1036,7 @@ describe("NumericFilterEditor (between)", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ }));
+ await openValueEditor(user);
const inputs = await screen.findAllByRole("spinbutton");
await user.clear(inputs[0]);
@@ -872,13 +1060,14 @@ describe("NumericFilterEditor (between)", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Count between 10/ }));
+ await openValueEditor(user);
const inputs = await screen.findAllByRole("spinbutton");
// min should already be "10", max should be empty
expect((inputs[0] as HTMLInputElement).value).toBe("10");
expect((inputs[1] as HTMLInputElement).value).toBe("");
+ // A half-filled range is invalid, so Apply is disabled and commits nothing.
await user.click(screen.getByRole("button", { name: "Apply" }));
expect(control.addFilter).not.toHaveBeenCalled();
@@ -893,7 +1082,7 @@ describe("NumericFilterEditor (between)", () => {
wrapper,
});
- await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ }));
+ await openValueEditor(user);
const inputs = await screen.findAllByRole("spinbutton");
await user.clear(inputs[0]);
@@ -909,7 +1098,7 @@ describe("NumericFilterEditor (between)", () => {
// ---------------------------------------------------------------------------
describe("TemporalFilterEditor (between)", () => {
- it("shows two date pickers when filter operator is between", async () => {
+ it("shows a date range value (en dash, medium dates) and two date pickers", async () => {
const user = userEvent.setup();
const control = makeControl({
filters: [
@@ -922,11 +1111,14 @@ describe("TemporalFilterEditor (between)", () => {
});
render( , { wrapper });
- await user.click(
- screen.getByRole("button", {
- name: /Created At between/,
- }),
- );
+ // The value segment renders " – " using medium dates (en dash).
+ expect(
+ screen.getByText(
+ (content) => content.includes("Jan 1, 2025") && content.includes("Dec 31, 2025"),
+ ),
+ ).toBeDefined();
+
+ await openValueEditor(user);
await screen.findByRole("button", { name: "Apply" });
expect(screen.getAllByRole("group")).toHaveLength(2);
@@ -945,11 +1137,7 @@ describe("TemporalFilterEditor (between)", () => {
});
render( , { wrapper });
- await user.click(
- screen.getByRole("button", {
- name: /Created At between/,
- }),
- );
+ await openValueEditor(user);
await screen.findByRole("button", { name: "Apply" });
await typeDateInto(user, 0, "2026-03-01");
@@ -975,11 +1163,7 @@ describe("TemporalFilterEditor (between)", () => {
});
render( , { wrapper });
- await user.click(
- screen.getByRole("button", {
- name: /Created At between/,
- }),
- );
+ await openValueEditor(user);
await screen.findByRole("button", { name: "Apply" });
await clearDateIn(user, 0);
diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx
index c679b9c1..bbaca82e 100644
--- a/packages/core/src/components/data-table/toolbar.tsx
+++ b/packages/core/src/components/data-table/toolbar.tsx
@@ -1,6 +1,6 @@
-import { useCallback, useMemo, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Popover } from "@base-ui/react/popover";
-import { ChevronDown, Plus, X } 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";
@@ -8,12 +8,20 @@ import { Input } from "@/components/input";
import { Checkbox } from "@/components/checkbox";
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 { DataTableColumnSettings } from "./column-settings";
import { useDataTableContext } from "./data-table-context";
import { useDataTableT } from "./i18n";
-import type { CollectionControl, Filter, FilterConfig, FilterOperator } from "@/types/collection";
+import type {
+ CollectionControl,
+ Filter,
+ FilterConfig,
+ FilterOperator,
+ SelectOption,
+} from "@/types/collection";
import type { Column } from "./types";
// =============================================================================
@@ -118,7 +126,25 @@ 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",
+ addIconOnly = false,
+}: {
+ 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";
+ /** Render the **Add filter** trigger as an icon-only button (label → `aria-label`). */
+ addIconOnly?: boolean;
+}) {
const ctx = useDataTableContext();
const control = useCollectionControlOptional();
if (!control) {
@@ -133,40 +159,693 @@ function DataTableFilters({ className }: { className?: string }) {
[ctx.columns],
);
- // Fields that currently have an active filter
- const activeFields = useMemo(
- () => new Set(control.filters.map((f) => f.field)),
- [control.filters],
+ 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. 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 (
+
+ );
+ }
+
+ // 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 (
+
+
+ {chips}
+
+ {/* Trigger stays pinned right so it doesn't shift as chips are added. */}
+
+
);
+}
+DataTableFilters.displayName = "DataTable.Filters";
+
+// =============================================================================
+// 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(
+ "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: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
+ * 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,
+ 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);
+ const [fieldName, setFieldName] = useState(columns[0]?.filter.field ?? "");
- // Fields available for the "Add filter" menu
- const availableColumns = useMemo(
- () => filterableColumns.filter((col) => !activeFields.has(col.filter.field)),
- [filterableColumns, activeFields],
+ const selectedColumn = columns.find((c) => c.filter.field === fieldName) ?? columns[0];
+ const config = selectedColumn?.filter;
+ const operators = config ? getAddFilterOperators(config.type) : [];
+ // 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(() =>
+ seedPanelOperator(control, selectedColumn),
);
- if (filterableColumns.length === 0) return null;
+ const selectField = (name: string) => {
+ setFieldName(name);
+ const col = columns.find((c) => c.filter.field === name);
+ 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;
+ if (config) {
+ effectiveOperator = showConditions ? operator : DEFAULT_OPERATOR[config.type];
+ }
+
+ return (
+
+
+ {/* 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")}
+
+ }
+ />
+
+ {/* 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 (scrolls), with a sticky "Clear all" footer */}
+
+
+ {columns.map((col) => {
+ const isSelected = col.filter.field === fieldName;
+ return (
+ selectField(col.filter.field)}
+ className={cn(
+ PANEL_COLUMN_ROW,
+ isSelected ? PANEL_ROW_SELECTED : PANEL_ROW_HOVER,
+ )}
+ >
+ {col.label ?? col.filter.field}
+ {activeFields.has(col.filter.field) && (
+
+ )}
+
+ );
+ })}
+
+ {control.filters.length > 0 && (
+
+ control.clearFilters()}
+ className="astw:w-full astw:justify-start astw:text-muted-foreground"
+ >
+ {t("clearAllFilters")}
+
+
+ )}
+
+
+ {/* Column 2 — conditions (only when the field opts into choosing one) */}
+ {showConditions && config && (
+
+ {operators.map((op) => (
+ setOperator(op)}
+ className={cn(
+ PANEL_COLUMN_ROW,
+ op === operator ? PANEL_ROW_SELECTED : PANEL_ROW_HOVER,
+ )}
+ >
+ {getOperatorLabel(op, t, config.type)}
+ {op === operator && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+
+
+
+
+ );
+}
+AddFilterPanel.displayName = "DataTable.AddFilterPanel";
+
+/**
+ * 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,
+ value,
+ onChange,
+}: {
+ ariaLabel: string;
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const calValue = /^\d{4}-\d{2}-\d{2}$/.test(value) ? parseDate(value) : null;
return (
+ // Center the fixed-width (w-fit) calendar within the wider value column.
e.stopPropagation()}
+ onMouseDownCapture={(e) => e.stopPropagation()}
>
- {/* Active filter chips */}
- {filterableColumns.map((col) => {
- const active = control.filters.find((f) => f.field === col.filter.field);
- if (!active) return null;
- return
;
- })}
-
- {/* Add filter button */}
- {availableColumns.length > 0 && (
-
+
onChange(v ? v.toString() : "")}
+ />
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// 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"`
+ * 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"
+ />
+
+
+ );
+}
+
+/**
+ * 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) => (
+ setActive(b.key)}
+ className={cn(
+ "astw:flex astw:flex-col astw:items-start astw:gap-0.5 astw:overflow-hidden astw:rounded-sm astw:px-2 astw:py-1 astw:text-left",
+ active === b.key && "astw:bg-background astw:shadow-sm",
+ )}
+ >
+ {b.label}
+
+ {fmt(b.value)}
+
+
+ ))}
+
+
+ {/* 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
+ * stays open so several filters can be added in a row).
+ */
+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;
+ const isBetween = operator === "between";
+
+ // Draft state, prefilled from an existing filter on the same field/operator.
+ const [enumSel, setEnumSel] = useState(
+ type === "enum" && Array.isArray(filter?.value) ? (filter.value as string[]) : [],
+ );
+ const [boolVal, setBoolVal] = useState(typeof filter?.value === "boolean" ? filter.value : true);
+ const [text, setText] = useState(() => {
+ if (isBetween) return "";
+ if (typeof filter?.value === "string") return filter.value;
+ if (typeof filter?.value === "number") return String(filter.value);
+ return "";
+ });
+ const range =
+ isBetween && filter?.value && typeof filter.value === "object"
+ ? (filter.value as { min?: unknown; max?: unknown })
+ : null;
+ const [min, setMin] = useState(range?.min != null ? String(range.min) : "");
+ const [max, setMax] = useState(range?.max != null ? String(range.max) : "");
+
+ const apply = () => {
+ if (type === "enum") {
+ if (enumSel.length === 0) control.removeFilter(field);
+ else control.addFilter(field, "in", enumSel);
+ return;
+ }
+ if (type === "boolean") {
+ control.addFilter(field, operator, boolVal);
+ return;
+ }
+ 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;
+ }
+ if (text.trim() === "") {
+ control.removeFilter(field);
+ return;
+ }
+ if (!isAddFilterDraftValueValid(type, operator, text)) return;
+ control.addFilter(
+ field,
+ operator,
+ toAddFilterSubmittedValue(type, operator, text),
+ // 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,
+ );
+ };
+
+ // 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 = (
+
+ setEnumSel((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]))
+ }
+ />
+ );
+ } else if (type === "boolean") {
+ editor = (
+
+ {[true, false].map((v) => (
+ setBoolVal(v)}
+ className={cn(PANEL_COLUMN_ROW, boolVal === v ? PANEL_ROW_SELECTED : PANEL_ROW_HOVER)}
+ >
+ {v ? t("filterBooleanTrue") : t("filterBooleanFalse")}
+ {boolVal === v && }
+
+ ))}
+
+ );
+ } else if (isBetween && type === "date") {
+ // Range dates: one inline calendar with From/To tabs on top.
+ editor = (
+
+ );
+ } else if (isBetween && type === "datetime") {
+ // Range datetimes: one inline calendar + time with From/To tabs on top.
+ editor = (
+
+ );
+ } 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 = (
+
+
+
+ );
+ } else if (type === "date") {
+ editor = (
+
+ );
+ } else if (type === "datetime") {
+ // Single datetime: inline calendar up front + a labelled time picker below.
+ editor = (
+
+ );
+ } else if (isTemporalFilterType(type)) {
+ // Single-value `time` uses the native time input. (`date`/`datetime` above.)
+ editor = (
+
+ setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") apply();
+ }}
+ className="astw:h-8 astw:text-sm"
+ />
+
+ );
+ } else {
+ editor = (
+
+ setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") apply();
+ }}
+ className="astw:h-8 astw:text-sm"
+ />
+
+ );
+ }
+
+ return (
+
+
{editor}
+
+ {/* Icon-only Clear, shown only when there's an active filter to remove. */}
+ {filter && (
+
+
+
+
+
+ }
+ />
+ {t("clearFilter")}
+
+
+ )}
+ {/* "Update" when re-editing an already-active field (the panel is seeded
+ from it), "Apply" when adding a fresh filter. */}
+
+ {filter ? t("updateFilter") : t("applyFilter")}
+
+
);
}
-DataTableFilters.displayName = "DataTable.Filters";
// =============================================================================
// BetweenInputGroup — shared UI for "between" filter inputs
@@ -179,6 +858,7 @@ function BetweenInputGroup({
onChangeMax,
onSubmit,
inputProps,
+ error,
}: {
labels: [string, string];
values: [string, string];
@@ -186,43 +866,79 @@ 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]}
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"
+ className={inputCell}
/>
-
-
- {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.
@@ -242,331 +958,54 @@ function DateFilterPicker({
aria-label={ariaLabel}
value={calValue}
onChange={(v) => onChange(v ? v.toString() : "")}
- className="astw:w-full"
- />
- );
-}
-
-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 (
-
- {
- const current = new Set(selectedValues);
- if (current.has(option.value)) {
- current.delete(option.value);
- } else {
- current.add(option.value);
- }
- setValue([...current]);
- }}
- />
- {option.label}
-
- );
- })}
-
- );
- }
-
- if (config.type === "boolean") {
- return (
- {
- if (v) setValue(v);
- }}
- mapItem={(v) => ({
- value: v,
- label: v === "true" ? t("filterBooleanTrue") : t("filterBooleanFalse"),
- })}
- className="astw:h-8 astw:text-sm"
- />
- );
- }
-
- if (isTemporalFilterType(config.type)) {
- const isDate = config.type === "date";
- const fieldLabel = selectedColumn.label ?? config.field;
- if (operator === "between") {
- const [min, max] = Array.isArray(value) ? value : ["", ""];
- if (isDate) {
- return (
-
- setValue([v, max])}
- />
- setValue([min, v])}
- />
-
- );
- }
- return (
- setValue([v, max])}
- onChangeMax={(v) => setValue([min, v])}
- onSubmit={handleSubmit}
- inputProps={getTemporalInputProps(config.type)}
- />
- );
- }
- if (isDate) {
- return (
- setValue(v)}
- />
- );
- }
- 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"
- />
- );
- }
+ className="astw:w-full"
+ />
+ );
+}
- return (
- setValue(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- handleSubmit();
- }
- }}
- className="astw:h-8 astw:text-sm"
- />
- );
+/**
+ * Datetime filter input: the app-shell date `DatePicker` (calendar) paired with a
+ * native time box, bridging an ISO `"YYYY-MM-DDTHH:mm:ss"` string. Entering a full
+ * datetime by hand is awkward, so the date and time are picked separately and
+ * combined. This is a stopgap — it's replaced 1:1 once a dedicated DateTime picker
+ * component lands.
+ */
+function DateTimeFilterInput({
+ ariaLabel,
+ value,
+ onChange,
+}: {
+ ariaLabel: string;
+ value: string;
+ onChange: (value: string) => 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 (
-
-
-
- {t("addFilter")}
-
- }
+
);
}
@@ -588,39 +1027,133 @@ 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) => {
+ // 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(
+ 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 = toNum(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" ? toNum(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 ? (
+ // 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}
+ ) : (
+ …
+ )}
-
+
}
/>
- {/* See AddFilterPopover — stacking context so the popup clears the
- sticky table header. */}
+ {/* Stacking context on the portal so the popup clears the sticky table 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 }) => (
+ onSelect(op)}
+ className={cn(
+ "astw:flex astw:w-full astw:items-center astw:justify-between astw:gap-2 astw:rounded-sm astw:px-2 astw:py-1.5 astw:text-left astw:text-sm astw:outline-hidden",
+ "astw:hover:bg-accent astw:hover:text-accent-foreground astw:focus-visible:bg-accent",
+ op === current && "astw:bg-accent/60",
+ )}
+ >
+ {label}
+ {op === current && }
+
+ ))}
+ {items.length === 0 && (
+
+ {t("filterOperatorNoResults")}
+
+ )}
+
);
}
@@ -662,11 +1267,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;
@@ -676,11 +1288,23 @@ function FilterPopoverContent({
return ;
case "boolean":
return (
-
+
);
case "string":
return (
-
+
);
case "uuid":
return (
@@ -688,7 +1312,13 @@ function FilterPopoverContent({
);
case "number":
return (
-
+
);
case "datetime":
case "date":
@@ -700,11 +1330,47 @@ function FilterPopoverContent({
filter={filter}
control={control}
onClose={onClose}
+ hideOperator={hideOperator}
/>
);
}
}
+/**
+ * Multi-select option list for enum filters. Rendered identically in the
+ * add-filter panel 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) => (
+
+ onToggle(option.value)}
+ />
+ {option.label}
+
+ ))}
+
+ );
+}
+
// =============================================================================
// Enum filter — checkbox list (matching the screenshot)
// =============================================================================
@@ -742,23 +1408,7 @@ function EnumFilterEditor({
);
return (
-
- {config.options.map((option) => {
- const isChecked = selectedValues.includes(option.value);
- return (
-
- handleToggle(option.value)} />
- {option.label}
-
- );
- })}
-
+
);
}
@@ -774,11 +1424,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(
@@ -800,15 +1452,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"
- />
+ {!hideOperator && (
+ {
+ if (v) setLocalOp(v as BooleanOperator);
+ }}
+ mapItem={(op) => ({ value: op, label: getOperatorLabel(op, t) })}
+ className="astw:h-8 astw:text-sm"
+ />
+ )}
;
filter: Filter;
control: CollectionControl;
onClose: () => void;
+ hideOperator?: boolean;
}) {
const t = useDataTableT();
const [localOp, setLocalOp] = useState(
@@ -868,19 +1524,23 @@ function StringFilterEditor({
data-slot="data-table-filter-string"
className="astw:flex astw:flex-col astw:gap-2 astw:p-2"
>
- {
- if (v) setLocalOp(v);
- }}
- mapItem={(op) => ({ value: op, label: t(`filterOperator_${op}`) })}
- className="astw:h-8 astw:text-sm"
- />
+ {!hideOperator && (
+ {
+ if (v) setLocalOp(v);
+ }}
+ mapItem={(op) => ({ value: op, label: t(`filterOperator_${op}`) })}
+ className="astw:h-8 astw:text-sm"
+ />
+ )}
setLocalValue(e.target.value)}
onKeyDown={(e) => {
+ e.stopPropagation();
if (e.key === "Enter") handleCommit();
}}
className="astw:h-8 astw:text-sm"
@@ -929,8 +1589,10 @@ function UuidFilterEditor({
setLocalValue(e.target.value)}
onKeyDown={(e) => {
+ e.stopPropagation();
if (e.key === "Enter") handleCommit();
}}
className="astw:h-8 astw:text-sm"
@@ -951,11 +1613,13 @@ function NumericFilterEditor({
filter,
control,
onClose,
+ hideOperator = false,
}: {
config: Extract
;
filter: Filter;
control: CollectionControl;
onClose: () => void;
+ hideOperator?: boolean;
}) {
const t = useDataTableT();
const { items: operatorItems, initial: initialOp } = resolveTemporalOperator(
@@ -984,7 +1648,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));
})();
@@ -998,7 +1666,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;
@@ -1022,15 +1690,17 @@ function NumericFilterEditor({
data-slot="data-table-filter-number"
className="astw:flex astw:flex-col astw:gap-2 astw:p-2"
>
- {
- if (v) setLocalOp(v);
- }}
- mapItem={(op) => ({ value: op, label: getOperatorLabel(op, t, config.type) })}
- className="astw:h-8 astw:text-sm"
- />
+ {!hideOperator && (
+ {
+ if (v) setLocalOp(v);
+ }}
+ mapItem={(op) => ({ value: op, label: getOperatorLabel(op, t, config.type) })}
+ className="astw:h-8 astw:text-sm"
+ />
+ )}
{localOp === "between" ? (
) : (
setLocalValue(e.target.value)}
onKeyDown={(e) => {
+ e.stopPropagation();
if (e.key === "Enter") handleCommit();
}}
className="astw:h-8 astw:text-sm"
@@ -1068,6 +1748,7 @@ function TemporalFilterEditor({
filter,
control,
onClose,
+ hideOperator = false,
}: {
config: Extract;
/** The column's visible label — used for the date picker's accessible name. */
@@ -1075,6 +1756,7 @@ function TemporalFilterEditor({
filter: Filter;
control: CollectionControl;
onClose: () => void;
+ hideOperator?: boolean;
}) {
const t = useDataTableT();
const { items: operatorItems, initial: initialOp } = resolveTemporalOperator(
@@ -1105,7 +1787,8 @@ function TemporalFilterEditor({
if (minEmpty || maxEmpty) return false; // both required
return (
isTemporalFilterValueValid(config.type, localValue) &&
- isTemporalFilterValueValid(config.type, localValueMax)
+ isTemporalFilterValueValid(config.type, localValueMax) &&
+ isRangeOrdered(config.type, localValue, localValueMax)
);
}
return localValue.trim() === "" || isTemporalFilterValueValid(config.type, localValue);
@@ -1121,6 +1804,7 @@ function TemporalFilterEditor({
const minValid = isTemporalFilterValueValid(config.type, localValue);
const maxValid = isTemporalFilterValueValid(config.type, localValueMax);
if (!minValid || !maxValid) return;
+ if (!isRangeOrdered(config.type, localValue, localValueMax)) return;
control.addFilter(config.field, localOp, {
min: localValue,
max: localValueMax,
@@ -1141,49 +1825,68 @@ 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(
+ config.type,
+ localValue,
+ localValueMax,
+ t("filterBetweenFrom"),
+ t("filterBetweenTo"),
+ t,
+ )
+ : undefined;
let valueInput: ReactNode;
if (localOp === "between") {
- valueInput = isDate ? (
-
-
-
+
+
+ {betweenError && {betweenError}
}
+
+ ) : (
+
-
- ) : (
-
- );
+ );
} else {
- valueInput = isDate ? (
-
- ) : (
- {
- setLocalValue(e.target.value);
- }}
- onKeyDown={(e) => {
- 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 (
@@ -1191,15 +1894,17 @@ function TemporalFilterEditor({
data-slot={`data-table-filter-${config.type}`}
className="astw:flex astw:flex-col astw:gap-2 astw:p-2"
>
- {
- if (v) setLocalOp(v);
- }}
- mapItem={(op) => ({ value: op, label: getOperatorLabel(op, t, config.type) })}
- className="astw:h-8 astw:text-sm"
- />
+ {!hideOperator && (
+ {
+ if (v) setLocalOp(v);
+ }}
+ mapItem={(op) => ({ value: op, label: getOperatorLabel(op, t, config.type) })}
+ className="astw:h-8 astw:text-sm"
+ />
+ )}
{valueInput}
{t("applyFilter")}
@@ -1231,12 +1936,6 @@ function getAddFilterOperators(type: FilterConfig["type"]): FilterOperator[] {
}
}
-function getInitialAddFilterDraftValue(type: FilterConfig["type"]): AddFilterDraftValue {
- if (type === "enum") return [];
- if (type === "boolean") return "true";
- return "";
-}
-
function isAddFilterDraftValueValid(
type: FilterConfig["type"],
operator: FilterOperator,
@@ -1318,13 +2017,28 @@ 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;
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":
@@ -1417,16 +2131,52 @@ function formatDateValue(iso: string, locale: string): string {
}
}
+/**
+ * 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
+ * an en dash. (A tighter same-month collapse was avoided — partial Intl date
+ * parts render inconsistently across locales.)
+ */
+function formatDateRange(minIso: string, maxIso: string, locale: string): string {
+ return [minIso && formatDateValue(minIso, locale), maxIso && formatDateValue(maxIso, locale)]
+ .filter(Boolean)
+ .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,
t: ReturnType,
locale: string,
+ /** Column label — used to summarize multi-select enums as "N labels". */
+ label?: string,
): string {
if (config.type === "enum" && Array.isArray(filter.value)) {
const labels = filter.value
.map((v) => config.options.find((option) => option.value === v)?.label ?? String(v))
.filter((v) => v !== "");
+ // Summarize multiple selections as "2 Status(s)" rather than listing them.
+ if (labels.length > 1 && label) {
+ return t("filterEnumCount", { count: labels.length, noun: label });
+ }
return labels.join(", ");
}
@@ -1446,14 +2196,26 @@ function formatFilterValue(
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 ? formatDateValue(String(range.min), locale) : "";
- const max = range.max != null ? formatDateValue(String(range.max), locale) : "";
- return [min, max].filter(Boolean).join(" - ");
+ const minIso = range.min != null ? String(range.min) : "";
+ const maxIso = range.max != null ? String(range.max) : "";
+ return formatDateRange(minIso, maxIso, locale);
}
if (filter.value == null || filter.value === "") return "";
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 "";
@@ -1470,34 +2232,4 @@ function formatFilterValue(
return String(filter.value);
}
-function getChipDisplayLabel(
- columnLabel: string,
- filter: Filter,
- config: FilterConfig,
- t: ReturnType,
- locale: string,
-): string {
- const valueLabel = formatFilterValue(filter, config, t, locale);
- if (!valueLabel) return columnLabel;
-
- const operatorLabel = getOperatorLabel(filter.operator, t, config.type);
- const ciSuffix = filter.caseSensitive ? " (Aa)" : "";
-
- if (config.type === "enum") {
- return t("filterChipLabelEnum", {
- column: columnLabel,
- operator: operatorLabel,
- value: valueLabel,
- });
- }
-
- return (
- t("filterChipLabel", {
- column: columnLabel,
- operator: operatorLabel,
- value: valueLabel,
- }) + ciSuffix
- );
-}
-
export { DataTableToolbar, DataTableFilters };