diff --git a/.changeset/data-table-filter-left-and-column-ui.md b/.changeset/data-table-filter-left-and-column-ui.md new file mode 100644 index 00000000..c665baa7 --- /dev/null +++ b/.changeset/data-table-filter-left-and-column-ui.md @@ -0,0 +1,11 @@ +--- +"@tailor-platform/app-shell": patch +--- + +DataTable filter and column UI fixes: + +- **Filter placement & style**: `DataTable.Filters` now renders the **Add filter** trigger on the **left** by default (was right-aligned), with active chips flowing to its right, and the trigger is **icon-only by default** (the label is kept as an `aria-label`). Pass `addIconOnly={false}` to show the "Add filter" text label. The add-filter popover now anchors to the left edge of the trigger. +- **Pinned columns**: closed a sub-pixel gap between adjacent frozen columns where scrolling rows could bleed through — column offsets are now measured with fractional widths so pinned columns sit flush. +- **Column settings popup**: added a search box at the top that filters the **Scrollable** column list by name, and that list now scrolls within a height cap while the **Fixed left** / **Fixed right** zones and the Show/Hide-all footer stay pinned and always fully visible — so the popup stays within the viewport even with many columns. Drag-to-reorder and drag-between-zones keep working while searching. The popup width is capped so long column names truncate. +- **Add-filter panel**: the field picker now has a **search box** to quickly find a field, and the field column hugs the field-name width (up to a cap) so names aren't needlessly truncated while the value editor keeps its width. +- **Truncated names**: long field/column names in the add-filter and column-settings pickers carry a native `title` so the full text shows on hover. diff --git a/examples/vite-app/src/pages/data-table-lab/page.tsx b/examples/vite-app/src/pages/data-table-lab/page.tsx index 3c42a414..2e6c113f 100644 --- a/examples/vite-app/src/pages/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/data-table-lab/page.tsx @@ -28,12 +28,26 @@ type Invoice = { issued: string; dueDate: string; notes: string; + poNumber: string; + terms: string; + currency: string; + category: string; + priority: string; + department: string; + discount: number; + balance: number; + createdBy: string; + lastContact: string; }; const CUSTOMERS = ["Acme Corp", "Globex", "Initech", "Umbrella", "Soylent", "Hooli", "Stark Ind."]; const REGIONS = ["North America", "EMEA", "APAC", "LATAM"]; const OWNERS = ["A. Kimura", "B. Osei", "C. Lindqvist", "D. Alvarez", "E. Nakamura"]; const STATUSES: InvoiceStatus[] = ["draft", "sent", "paid", "overdue"]; +const TERMS = ["Net 15", "Net 30", "Net 45", "Net 60"]; +const CATEGORIES = ["Software", "Hardware", "Services", "Support", "Consulting"]; +const PRIORITIES = ["Low", "Medium", "High", "Urgent"]; +const DEPARTMENTS = ["Sales", "Finance", "Operations", "Marketing", "Legal"]; // Deterministic pseudo-random so the dataset is stable across renders/reloads. function makeInvoices(count: number): Invoice[] { @@ -64,6 +78,18 @@ function makeInvoices(count: number): Invoice[] { issued: issued.toISOString().slice(0, 10), dueDate: due.toISOString().slice(0, 10), notes: `Follow-up scheduled with ${pick(OWNERS)} regarding ${pick(REGIONS)} terms and renewal.`, + poNumber: `PO-${String(5000 + i)}`, + terms: pick(TERMS), + currency: "USD", + category: pick(CATEGORIES), + priority: pick(PRIORITIES), + department: pick(DEPARTMENTS), + discount: Math.round(rand() * 15 * 10) / 10, + balance: Math.round(rand() * amount * 100) / 100, + createdBy: pick(OWNERS), + lastContact: new Date(base + Math.floor(rand() * 120) * 86_400_000) + .toISOString() + .slice(0, 10), }); } return rows; @@ -147,7 +173,7 @@ const baseColumns = [ }), column({ id: "email", - label: "Billing email", + label: "Billing email address for invoicing", type: "text", accessor: (r) => r.email, width: 240, @@ -166,7 +192,7 @@ const baseColumns = [ }), column({ id: "owner", - label: "Account owner", + label: "Primary account owner / relationship manager", type: "text", accessor: (r) => r.owner, width: 160, @@ -203,12 +229,88 @@ const baseColumns = [ }), column({ id: "notes", - label: "Notes", + label: "Internal notes, follow-up commentary, and account renewal reminders", type: "text", accessor: (r) => r.notes, width: 260, truncate: true, }), + // Extra columns to exercise the column-settings popup with 20+ entries. + column({ + id: "poNumber", + label: "Purchase order reference number", + type: "text", + accessor: (r) => r.poNumber, + width: 130, + }), + column({ + id: "terms", + label: "Standard payment terms and conditions", + type: "text", + accessor: (r) => r.terms, + width: 150, + }), + column({ + id: "currency", + label: "Currency", + type: "text", + accessor: (r) => r.currency, + width: 110, + }), + column({ + id: "category", + label: "Category", + type: "text", + accessor: (r) => r.category, + width: 150, + filter: { + field: "category", + type: "enum", + options: CATEGORIES.map((c) => ({ value: c, label: c })), + }, + }), + column({ + id: "priority", + label: "Priority", + type: "text", + accessor: (r) => r.priority, + width: 120, + }), + column({ + id: "department", + label: "Department", + type: "text", + accessor: (r) => r.department, + width: 150, + }), + column({ + id: "discount", + label: "Discount", + type: "text", + accessor: (r) => `${r.discount}%`, + width: 110, + }), + column({ + id: "balance", + label: "Balance", + type: "money", + accessor: (r) => r.balance, + width: 130, + }), + column({ + id: "createdBy", + label: "Created by", + type: "text", + accessor: (r) => r.createdBy, + width: 150, + }), + column({ + id: "lastContact", + label: "Last contact", + type: "date", + accessor: (r) => r.lastContact, + width: 150, + }), ]; const rowActions = [ @@ -255,7 +357,14 @@ const DataTableLabPage = () => { // Column settings + default pins + row actions. `tableId` persists the user's // layout (visibility, order, pinning) to localStorage across reloads. const settingsTable = useDataTable({ - columns: baseColumns.map((c) => (c.id === "id" ? { ...c, pin: "left" as const } : c)), + columns: baseColumns.map((c) => { + const pinned = c.id === "id" ? { ...c, pin: "left" as const } : c; + // Demo: make every column filterable so the add-filter field list is long + // enough to surface the field search (AppShell shows it past a threshold). + return pinned.filter + ? pinned + : { ...pinned, filter: { field: c.id as string, type: "string" as const } }; + }), data: { rows, total: rows.length }, control, tableId: "lab-invoices-settings", diff --git a/packages/core/src/components/data-table/column-settings.test.tsx b/packages/core/src/components/data-table/column-settings.test.tsx index f2057049..8dd1883e 100644 --- a/packages/core/src/components/data-table/column-settings.test.tsx +++ b/packages/core/src/components/data-table/column-settings.test.tsx @@ -141,6 +141,100 @@ describe("DataTable.Toolbar columnSettings", () => { expect(alphaHead?.style.position).toBe(""); }); + it("search filters visible columns and shows an empty state for no matches", () => { + render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + const search = within(panel).getByPlaceholderText("Search columns"); + + fireEvent.change(search, { target: { value: "brav" } }); + expect(within(panel).getByText("Bravo")).toBeTruthy(); + expect(within(panel).queryByText("Alpha")).toBeNull(); + expect(within(panel).queryByText("Charlie")).toBeNull(); + + fireEvent.change(search, { target: { value: "zzz" } }); + expect(within(panel).getByText(/no columns match/i)).toBeTruthy(); + + // Clearing the query restores every column. + fireEvent.change(search, { target: { value: "" } }); + expect(within(panel).getByText("Alpha")).toBeTruthy(); + expect(within(panel).getByText("Bravo")).toBeTruthy(); + expect(within(panel).getByText("Charlie")).toBeTruthy(); + }); + + it("search filters only the Scrollable list — pinned zones stay in full", () => { + // "Alpha" is pinned left; the search must never hide it. + const pinnedColumns: Column[] = columns.map((c) => + c.id === "a" ? { ...c, pin: "left" as const } : c, + ); + function Harness() { + const table = useDataTable({ columns: pinnedColumns, data }); + return ( + + + + + ); + } + render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + const search = within(panel).getByPlaceholderText("Search columns"); + fireEvent.change(search, { target: { value: "brav" } }); + + const left = panel.querySelector('[data-section="left"]')!; + const scrollable = panel.querySelector('[data-section="scrollable"]')!; + // Pinned-left zone is unaffected by the query. + expect(within(left).getByText("Alpha")).toBeTruthy(); + // Scrollable zone is filtered to the match. + expect(within(scrollable).getByText("Bravo")).toBeTruthy(); + expect(within(scrollable).queryByText("Charlie")).toBeNull(); + }); + + it("keeps rows draggable while searching", () => { + render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + const search = within(panel).getByPlaceholderText("Search columns"); + fireEvent.change(search, { target: { value: "brav" } }); + + const bravoRow = within(panel).getByText("Bravo").closest('[draggable="true"]'); + expect(bravoRow).not.toBeNull(); + }); + + it("dragging a filtered row into a pinned zone still works while searching", () => { + const { container } = render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + // Filter the Scrollable list down to just "Charlie", then drag it left. + const search = within(panel).getByPlaceholderText("Search columns"); + fireEvent.change(search, { target: { value: "charl" } }); + + const charlieRow = within(panel).getByText("Charlie").closest('[draggable="true"]')!; + const leftZone = panel.querySelector('[data-section="left"]')!; + fireEvent.dragStart(charlieRow); + fireEvent.dragOver(leftZone); + fireEvent.drop(leftZone); + + const charlieHead = Array.from( + container.querySelectorAll('[data-slot="data-table-header"] th'), + ).find((th) => th.textContent?.trim() === "Charlie"); + expect(charlieHead?.style.position).toBe("sticky"); + expect(charlieHead?.style.left).toBe("0px"); + }); + it("show all / hide all toggle every column", () => { const { container } = render(, { wrapper }); fireEvent.click(screen.getByRole("button", { name: /columns/i })); diff --git a/packages/core/src/components/data-table/column-settings.tsx b/packages/core/src/components/data-table/column-settings.tsx index b7b31691..6aed2ef9 100644 --- a/packages/core/src/components/data-table/column-settings.tsx +++ b/packages/core/src/components/data-table/column-settings.tsx @@ -1,16 +1,19 @@ -import { useCallback, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; import { Popover } from "@base-ui/react/popover"; -import { GripVertical, SlidersHorizontal } from "lucide-react"; +import { GripVertical, Search, SlidersHorizontal } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/button"; import { Checkbox } from "@/components/checkbox"; +import { Input } from "@/components/input"; import { Tooltip } from "@/components/tooltip"; import { useDataTableContext } from "./data-table-context"; import { useDataTableT } from "./i18n"; -// Shared popup styling, matching DataTable's filter popover. +// Shared popup styling, matching DataTable's filter popover. The width cap lives +// on the popup itself (not the inner fieldset) so base-ui measures the real width +// and anchors it flush under the trigger. const POPUP_CLASS = cn( - "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:origin-(--transform-origin) astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md", + "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:origin-(--transform-origin) astw:max-w-[360px] astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md", "astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:fade-out-0 astw:data-ending-style:zoom-out-95", ); @@ -102,6 +105,18 @@ function DataTableColumnSettings({ className }: { className?: string }) { return grouped; }, [columnOrder, sectionOf]); + // Free-text filter over column labels. Only the Scrollable list narrows to + // matches (the pinned zones always show in full); drag-and-drop stays enabled + // because drop positions are keyed to each row's full-order index (renderRow). + const [query, setQuery] = useState(""); + const q = query.trim().toLowerCase(); + const isSearching = q.length > 0; + const matchKeys = useCallback( + (keys: string[]) => + isSearching ? keys.filter((k) => (meta.label.get(k) ?? "").toLowerCase().includes(q)) : keys, + [isSearching, q, meta], + ); + const [dragKey, setDragKey] = useState(null); const [dropTarget, setDropTarget] = useState<{ section: Section; index: number } | null>(null); @@ -135,18 +150,22 @@ function DataTableColumnSettings({ className }: { className?: string }) { resetDrag(); }; - const renderRow = (key: string, section: Section, index: number, isLast: boolean) => { + const renderRow = (key: string, section: Section, isLast: boolean) => { + // Drop positions are keyed to the FULL bucket index (not the rendered + // position), so drag-and-drop stays correct even while the Scrollable list + // is filtered by search — a hovered row maps to its real slot in the order. + const fullIndex = buckets[section].indexOf(key); // Insertion indicator: an absolutely-positioned line that sits in the gap // between rows (straddling the shared border). Being absolute, it never // shifts the rows under the cursor — a flow element there made the drop // target recompute on every reflow and the line "jump". const showBefore = - dragKey != null && dropTarget?.section === section && dropTarget.index === index; + dragKey != null && dropTarget?.section === section && dropTarget.index === fullIndex; const showAfter = dragKey != null && dropTarget?.section === section && isLast && - dropTarget.index === index + 1; + dropTarget.index === fullIndex + 1; return (
rect.top + rect.height / 2; - setDropTarget({ section, index: after ? index + 1 : index }); + setDropTarget({ section, index: after ? fullIndex + 1 : fullIndex }); }} className={cn( "astw:relative astw:flex astw:items-center astw:gap-2 astw:rounded-sm astw:py-1 astw:pr-2 astw:pl-1.5 astw:hover:bg-accent", @@ -180,9 +199,10 @@ function DataTableColumnSettings({ className }: { className?: string }) { {/* The checkbox itself is the trigger (props, incl. ref in React 19, @@ -203,17 +223,45 @@ function DataTableColumnSettings({ className }: { className?: string }) { ); }; - const renderSection = (section: Section, title: string) => { - const keys = buckets[section]; + const renderSection = ( + section: Section, + title: string, + opts?: { scroll?: boolean; filter?: boolean }, + ) => { + // Search only narrows the section that opts in (the Scrollable list); the + // pinned zones always show their full contents. + const filtered = !!opts?.filter && isSearching; + const keys = filtered ? matchKeys(buckets[section]) : buckets[section]; const active = dragKey != null && dropTarget?.section === section; + let rows: ReactNode; + if (keys.length === 0) { + rows = filtered ? ( + // Filtered to nothing — show a search empty state, not the drop zone. +
+ {t("noColumnsMatch")} +
+ ) : ( +
+ {t("dropColumnsHere")} +
+ ); + } else { + rows = keys.map((key, i) => renderRow(key, section, i === keys.length - 1)); + } return (
{ e.preventDefault(); // Only fires for the header / padding / empty area (rows stop - // propagation) → drop at the end of the section. - setDropTarget({ section, index: keys.length }); + // propagation) → drop at the end of the section. Use the full bucket + // length (not the filtered count) so a drop lands at the true end. + setDropTarget({ section, index: buckets[section].length }); }} onDrop={(e) => { e.preventDefault(); @@ -229,17 +277,14 @@ function DataTableColumnSettings({ className }: { className?: string }) {
{title}
- {keys.length === 0 ? ( -
- {t("dropColumnsHere")} + {/* Only the Scrollable section gets an internal scroll cap; the pinned + zones stay fully visible. */} + {opts?.scroll ? ( +
+ {rows}
) : ( - keys.map((key, i) => renderRow(key, section, i, i === keys.length - 1)) + rows )}
); @@ -264,9 +309,23 @@ function DataTableColumnSettings({ className }: { className?: string }) { aria-label={t("columnSettings")} className="astw:m-0 astw:flex astw:min-w-72 astw:flex-col astw:border-0 astw:p-2" > + {/* Search sits above the pinned-left section; it filters only the + Scrollable list — the Fixed left / right zones always show in + full. */} +
+ + setQuery(e.target.value)} + placeholder={t("searchColumns")} + aria-label={t("searchColumns")} + className="astw:h-8 astw:pl-8 astw:text-sm" + /> +
{renderSection("left", t("sectionPinnedLeft"))}
- {renderSection("scrollable", t("sectionScrollable"))} + {renderSection("scrollable", t("sectionScrollable"), { scroll: true, filter: true })}
{renderSection("right", t("sectionPinnedRight"))}
diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 115c0a42..c9e2bda6 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -984,7 +984,11 @@ function DataTableTable({ className }: { className?: string }) { const next: ColumnWidths = {}; cells.forEach((cell) => { const key = cell.dataset.colKey; - if (key) next[key] = cell.offsetWidth; + // Fractional width (not the integer-rounded offsetWidth) so accumulated + // sticky offsets land exactly on each column's real edge — otherwise the + // rounding drift opens a sub-pixel gap between adjacent pinned columns + // where scrolling rows bleed through. + if (key) next[key] = cell.getBoundingClientRect().width; }); setWidths((prev) => (sameWidths(prev, next) ? prev : next)); }; diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 3a508f7c..cb7ffd27 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -25,6 +25,10 @@ export const dataTableLabels = defineI18nLabels({ dropColumnsHere: "Drag columns here", showColumn: "Show column", hideColumn: "Hide column", + searchColumns: "Search columns", + noColumnsMatch: "No columns match", + searchFields: "Search fields", + noFieldsMatch: "No fields match", // Row selection selectAll: "Select all rows", @@ -111,6 +115,10 @@ export const dataTableLabels = defineI18nLabels({ dropColumnsHere: "ここに列をドラッグ", showColumn: "列を表示", hideColumn: "列を非表示", + searchColumns: "列を検索", + noColumnsMatch: "一致する列がありません", + searchFields: "フィールドを検索", + noFieldsMatch: "一致するフィールドがありません", selectAll: "全行を選択", selectRow: "行を選択", diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index ca0d8e42..6745a061 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -224,7 +224,7 @@ describe("DataTable.Filters", () => { render(, { wrapper, }); - expect(screen.getByText("Add filter")).toBeDefined(); + expect(screen.getByRole("button", { name: "Add filter" })).toBeDefined(); }); it("still renders the add filter button when all filterable columns are active", () => { @@ -237,7 +237,7 @@ describe("DataTable.Filters", () => { render(, { wrapper, }); - expect(screen.getByText("Add filter")).toBeDefined(); + expect(screen.getByRole("button", { name: "Add filter" })).toBeDefined(); }); it("returns null when there are no filterable columns", () => { @@ -259,7 +259,7 @@ describe("DataTable.Filters", () => { filters: [{ field: "name", operator: "contains", value: "Alice" }], }); render(, { wrapper }); - expect(screen.getByText("Add filter")).toBeDefined(); + expect(screen.getByRole("button", { name: "Add filter" })).toBeDefined(); expect(document.querySelector('[data-slot="data-table-filter-chip"]')).toBeNull(); }); @@ -269,7 +269,7 @@ describe("DataTable.Filters", () => { }); render(, { wrapper }); expect(document.querySelector('[data-slot="data-table-filter-chip"]')).not.toBeNull(); - expect(screen.queryByText("Add filter")).toBeNull(); + expect(screen.queryByRole("button", { name: "Add filter" })).toBeNull(); }); it("slot='chips' renders nothing when there are no active filters", () => { @@ -281,15 +281,25 @@ describe("DataTable.Filters", () => { expect(container.querySelector('[data-slot="data-table-filters"]')).toBeNull(); }); - it("addIconOnly renders an icon-only trigger (label kept as aria-label)", () => { + it("renders an icon-only trigger by default (label kept as aria-label)", () => { const control = makeControl({ filters: [] }); - render(, { + 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(""); }); + + it("addIconOnly={false} renders the visible 'Add filter' text label", () => { + const control = makeControl({ filters: [] }); + render( + , + { wrapper }, + ); + const trigger = screen.getByRole("button", { name: "Add filter" }); + expect(trigger.textContent).toContain("Add filter"); + }); }); // --------------------------------------------------------------------------- @@ -313,6 +323,45 @@ describe("AddFilterPanel", () => { expect(screen.getByRole("button", { name: /^Count$/ })).toBeDefined(); }); + it("the field search filters the field list and shows an empty state", async () => { + const user = userEvent.setup(); + const control = makeControl({ filters: [] }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + const search = await screen.findByPlaceholderText("Search fields"); + + fireEvent.change(search, { target: { value: "coun" } }); + expect(screen.getByRole("button", { name: /^Count$/ })).toBeDefined(); + expect(screen.queryByRole("button", { name: /^Name$/ })).toBeNull(); + + fireEvent.change(search, { target: { value: "zzz" } }); + expect(screen.getByText(/no fields match/i)).toBeDefined(); + + fireEvent.change(search, { target: { value: "" } }); + expect(screen.getByRole("button", { name: /^Name$/ })).toBeDefined(); + expect(screen.getByRole("button", { name: /^Count$/ })).toBeDefined(); + }); + + it("advances the selection when the search filters out the active field", async () => { + const user = userEvent.setup(); + const control = makeControl({ filters: [] }); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + // Select the numeric "Count" field — its editor has no "contains" operator. + await user.click(await screen.findByRole("button", { name: /^Count$/ })); + expect(screen.queryByRole("button", { name: "contains" })).toBeNull(); + + // Searching "na" filters the list to "Name" only, filtering out the active + // "Count" field. Selection must advance to "Name" so the list and the editor + // stay in sync — the string editor's "contains" operator now appears. + fireEvent.change(screen.getByPlaceholderText("Search fields"), { target: { value: "na" } }); + expect(await screen.findByRole("button", { name: "contains" })).toBeDefined(); + }); + it("selecting a field shows the value editor with an Apply button", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [] }); diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index bbaca82e..3b74d5de 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -129,7 +129,7 @@ type AddFilterDraftValue = string | string[]; function DataTableFilters({ className, slot = "all", - addIconOnly = false, + addIconOnly = true, }: { className?: string; /** @@ -142,7 +142,10 @@ function DataTableFilters({ * different rows (e.g. the trigger in a header row, chips on the row below). */ slot?: "all" | "chips" | "add"; - /** Render the **Add filter** trigger as an icon-only button (label → `aria-label`). */ + /** + * Render the **Add filter** trigger as an icon-only button (label → `aria-label`). + * Defaults to `true`; pass `addIconOnly={false}` to show the "Add filter" text label. + */ addIconOnly?: boolean; }) { const ctx = useDataTableContext(); @@ -194,17 +197,18 @@ function DataTableFilters({ ); } - // Default: chips (grow to fill) + the right-aligned Add filter trigger. + // Default: the left-aligned Add filter trigger + chips (grow to fill) to its right. return (
+ {/* Trigger comes first so it stays pinned left and doesn't shift as chips are + added — the chips grow to its right inside their own flex-1 container. */} +
{chips}
- {/* Trigger stays pinned right so it doesn't shift as chips are added. */} -
); } @@ -258,6 +262,13 @@ function AddFilterPanel({ const t = useDataTableT(); const [open, setOpen] = useState(false); const [fieldName, setFieldName] = useState(columns[0]?.filter.field ?? ""); + // Field search: enterprise tables can have many filterable fields, so the + // panel always offers a search box over the field list. + const [fieldQuery, setFieldQuery] = useState(""); + const fq = fieldQuery.trim().toLowerCase(); + const visibleFieldColumns = fq + ? columns.filter((c) => (c.label ?? c.filter.field).toLowerCase().includes(fq)) + : columns; const selectedColumn = columns.find((c) => c.filter.field === fieldName) ?? columns[0]; const config = selectedColumn?.filter; @@ -276,10 +287,25 @@ function AddFilterPanel({ if (col) setOperator(seedPanelOperator(control, col)); }; + // Keep the selection in sync with the search: if the query filters out the + // currently-selected field, advance to the first still-visible field so the + // field list and the value editor don't desync (empty highlight on the left + // while the right still shows the old field's editor). + useEffect(() => { + if (!fq || visibleFieldColumns.length === 0) return; + if (visibleFieldColumns.some((c) => c.filter.field === fieldName)) return; + const first = visibleFieldColumns[0]; + setFieldName(first.filter.field); + setOperator(seedPanelOperator(control, first)); + }, [fq, visibleFieldColumns, fieldName, control]); + // 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 ?? ""); + if (next) { + setFieldQuery(""); + selectField(columns[0]?.filter.field ?? ""); + } }; const activeFields = new Set(control.filters.map((f) => f.field)); @@ -302,53 +328,74 @@ function AddFilterPanel({ } /> - {/* align="end" anchors the panel's right edge to the (right-aligned) trigger + {/* align="start" anchors the panel's left edge to the (left-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 + the left 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. */} - + nothing to jump away from. base-ui still shifts the panel to stay on-screen. */} + - {/* Column 1 — fields (scrolls), with a sticky "Clear all" footer */} -
-
- {columns.map((col) => { - const isSelected = col.filter.field === fieldName; - return ( - - ); - })} + {/* Column 1 — fields (scrolls), with a search header and a sticky + "Clear all" footer. Hugs the field-name width up to a cap so long + names aren't needlessly truncated. */} +
+ {/* Field search — spacing/style mirrors the ColumnSettings search. */} +
+ + setFieldQuery(e.target.value)} + placeholder={t("searchFields")} + aria-label={t("searchFields")} + className="astw:h-8 astw:pl-8 astw:text-sm" + /> +
+
+ {visibleFieldColumns.length === 0 ? ( +
+ {t("noFieldsMatch")} +
+ ) : ( + visibleFieldColumns.map((col) => { + const isSelected = col.filter.field === fieldName; + return ( + + ); + }) + )}
{control.filters.length > 0 && ( -
+