Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/data-table-filter-left-and-column-ui.md
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 113 additions & 4 deletions examples/vite-app/src/pages/data-table-lab/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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<Invoice>({
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SettingsHarness />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /columns/i }));

const panel = document.querySelector<HTMLElement>(
'[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<Row>[] = columns.map((c) =>
c.id === "a" ? { ...c, pin: "left" as const } : c,
);
function Harness() {
const table = useDataTable<Row>({ columns: pinnedColumns, data });
return (
<DataTable.Root value={table}>
<DataTable.Toolbar columnSettings />
<DataTable.Table />
</DataTable.Root>
);
}
render(<Harness />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /columns/i }));

const panel = document.querySelector<HTMLElement>(
'[data-slot="data-table-column-settings-popup"]',
)!;
const search = within(panel).getByPlaceholderText("Search columns");
fireEvent.change(search, { target: { value: "brav" } });

const left = panel.querySelector<HTMLElement>('[data-section="left"]')!;
const scrollable = panel.querySelector<HTMLElement>('[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(<SettingsHarness />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /columns/i }));

const panel = document.querySelector<HTMLElement>(
'[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(<SettingsHarness />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /columns/i }));

const panel = document.querySelector<HTMLElement>(
'[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<HTMLElement>('[data-section="left"]')!;
fireEvent.dragStart(charlieRow);
fireEvent.dragOver(leftZone);
fireEvent.drop(leftZone);

const charlieHead = Array.from(
container.querySelectorAll<HTMLElement>('[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(<SettingsHarness />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /columns/i }));
Expand Down
Loading
Loading