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) => ( + + ))} +
+ ); +} + +// 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 + } + /> + + {/* 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 ( + + ); + })} +
+ {control.filters.length > 0 && ( +
+ +
+ )} +
+ + {/* Column 2 — conditions (only when the field opts into choosing one) */} + {showConditions && config && ( +
+ {operators.map((op) => ( + + ))} +
+ )} + + {/* 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) => ( + + ))} +
+ + {/* 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) => ( + + ))} +
+ ); + } 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. */} + +
); } -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 ( - - ); - })} -
- ); - } - - if (config.type === "boolean") { - return ( - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - className="astw:h-8 astw:text-sm" - /> - ); - } - - if (config.type === "number") { - if (operator === "between") { - const [min, max] = Array.isArray(value) ? value : ["", ""]; - return ( - setValue([v, max])} - onChangeMax={(v) => setValue([min, v])} - onSubmit={handleSubmit} - inputProps={{ type: "number" }} - /> - ); - } - return ( - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - className="astw:h-8 astw:text-sm" - /> - ); - } + 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")} - - } +
+ emit(v ? v.toString() : "", timePart)} + className="astw:min-w-0 astw:flex-1" /> - {/* Stacking context on the portal container so the popup renders above - positioned elements like DataTable's sticky header row (z-index: 10) — - the Popup's own z-index is inert (it's position: static; the - Positioner is the positioned element). Same pattern as Menu/Tooltip. */} - - - -
- { - if (!nextOp) return; - const wasBetween = operator === "between"; - const isBetween = nextOp === "between"; - setOperator(nextOp); - if (wasBetween !== isBetween) { - setValue(isBetween ? ["", ""] : ""); - } - }} - mapItem={(op) => ({ - value: op, - label: getOperatorLabel(op, t, selectedColumn?.filter.type), - })} - className="astw:h-8 astw:text-sm" - /> - ) : null} - {renderValueEditor()} - {selectedColumn?.filter.type === "string" && ( - - )} - -
-
-
-
- + emit(datePart, e.target.value)} + className="astw:h-8 astw:w-28 astw:shrink-0 astw:text-sm" + /> +
); } @@ -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 }) => ( + + ))} + {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) => ( + + ))} +
+ ); +} + // ============================================================================= // 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 ( - - ); - })} -
+ ); } @@ -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" + /> + )} { - if (v) setLocalOp(v); - }} - mapItem={(op) => ({ value: op, label: t(`filterOperator_${op}`) })} - className="astw:h-8 astw:text-sm" - /> + {!hideOperator && ( + setLocalValue(e.target.value)} onKeyDown={(e) => { + e.stopPropagation(); if (e.key === "Enter") handleCommit(); }} className="astw:h-8 astw:text-sm" @@ -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" + /> + )} {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" + /> + )} {valueInput}