@elabs-ai/components-data 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/dist/index.d.ts +199 -5
- package/dist/index.js +764 -166
- package/dist/index.js.map +1 -1
- package/package.json +10 -7
- package/src/__contract__/filter-chip.contract.test.tsx +49 -0
- package/src/column-picker/column-picker.tsx +6 -3
- package/src/data-table/data-table.stories.tsx +496 -2
- package/src/data-table/data-table.test.tsx +1431 -17
- package/src/data-table/data-table.tsx +1284 -39
- package/src/data-table/index.ts +10 -0
- package/src/facet-filter/facet-filter.stories.tsx +4 -1
- package/src/facet-filter/facet-filter.test.tsx +3 -3
- package/src/facet-filter/facet-filter.tsx +5 -1
- package/src/filter-bar/filter-chip.stories.tsx +144 -0
- package/src/filter-bar/filter-chip.test.tsx +137 -0
- package/src/filter-bar/filter-chip.tsx +92 -0
- package/src/filter-bar/index.ts +1 -0
- package/src/index.ts +10 -1
- package/src/search-input/search-input.stories.tsx +3 -0
- package/src/search-input/search-input.test.tsx +35 -0
- package/src/search-input/search-input.tsx +45 -20
- package/src/templates-data-app.stories.tsx +6 -4
- package/src/to-csv.test.ts +13 -0
- package/src/to-csv.ts +7 -30
package/src/data-table/index.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
export {
|
|
2
2
|
DataTable,
|
|
3
|
+
createSelectionColumn,
|
|
3
4
|
type DataTableProps,
|
|
4
5
|
type DataTableViewState,
|
|
5
6
|
type DataTableServerArgs,
|
|
6
7
|
type DataTableRowClickHandler,
|
|
8
|
+
// #69 round-1 fix (validator B3): this named type was declared and
|
|
9
|
+
// documented as exported ("Exported (not just declared) so a consumer's
|
|
10
|
+
// own `ColumnDef` literal type-checks against a NAMED type") but was never
|
|
11
|
+
// actually re-exported through this barrel — a consumer importing it from
|
|
12
|
+
// `@elabs-ai/components-data` got `TS2305`. `ColumnMeta`'s augmented keys
|
|
13
|
+
// already reached consumers via `packages/data/src/index.ts`'s TanStack
|
|
14
|
+
// re-export; this makes the NAMED type reachable too, matching both the
|
|
15
|
+
// source comment and the CHANGELOG entry.
|
|
16
|
+
type DataTableColumnMeta,
|
|
7
17
|
} from "./data-table";
|
|
@@ -95,7 +95,10 @@ export const ToolbarAlignment: Story = {
|
|
|
95
95
|
<SelectItem value="staging">Staging</SelectItem>
|
|
96
96
|
</SelectContent>
|
|
97
97
|
</Select>
|
|
98
|
-
|
|
98
|
+
{/* DatePicker's trigger fills its container by default (`w-full`, like Input), which
|
|
99
|
+
in a wrapping flex row claims a line of its own. In a toolbar it sizes to its
|
|
100
|
+
content instead — the same per-toolbar sizing Select and Input get here. */}
|
|
101
|
+
<DatePicker placeholder="Pick a date" className="w-auto" />
|
|
99
102
|
<Input className="w-40" aria-label="Search services" placeholder="e.g. billing…" />
|
|
100
103
|
<Button variant="outline">Reset</Button>
|
|
101
104
|
</div>
|
|
@@ -57,7 +57,7 @@ describe("FacetFilter — trigger", () => {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* #346 — the trigger must take Button's DEFAULT size rung, not `sm`, so a
|
|
60
|
-
* toolbar mixing FacetFilter with Select/Input/DatePicker (all h-
|
|
60
|
+
* toolbar mixing FacetFilter with Select/Input/DatePicker (all `h-control`) lines up.
|
|
61
61
|
* jsdom applies no Tailwind, so the class rung is what is assertable here; the
|
|
62
62
|
* MEASURED proof lives in the `ToolbarAlignment` story's play function, which
|
|
63
63
|
* compares real `getBoundingClientRect()` boxes in a browser.
|
|
@@ -67,8 +67,8 @@ describe("FacetFilter — trigger", () => {
|
|
|
67
67
|
<FacetFilter title="Status" options={options} selected={[]} onSelectedChange={vi.fn()} />,
|
|
68
68
|
);
|
|
69
69
|
const trigger = screen.getByRole("button", { name: "Status" });
|
|
70
|
-
expect(trigger).toHaveClass("h-
|
|
71
|
-
expect(trigger).not.toHaveClass("h-
|
|
70
|
+
expect(trigger).toHaveClass("h-control");
|
|
71
|
+
expect(trigger).not.toHaveClass("h-control-sm");
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DropdownMenuLabel,
|
|
10
10
|
DropdownMenuSeparator,
|
|
11
11
|
DropdownMenuTrigger,
|
|
12
|
+
useLocale,
|
|
12
13
|
} from "@elabs-ai/components-ui";
|
|
13
14
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
14
15
|
|
|
@@ -42,6 +43,7 @@ export const FacetFilter = forwardRef<HTMLButtonElement, FacetFilterProps>(funct
|
|
|
42
43
|
{ title, options, selected, onSelectedChange, className, ...props },
|
|
43
44
|
ref,
|
|
44
45
|
) {
|
|
46
|
+
const { t } = useLocale();
|
|
45
47
|
const selectedSet = new Set(selected);
|
|
46
48
|
const toggle = (value: string) => {
|
|
47
49
|
const next = new Set(selectedSet);
|
|
@@ -93,7 +95,9 @@ export const FacetFilter = forwardRef<HTMLButtonElement, FacetFilterProps>(funct
|
|
|
93
95
|
{selected.length > 0 ? (
|
|
94
96
|
<>
|
|
95
97
|
<DropdownMenuSeparator />
|
|
96
|
-
<DropdownMenuItem onSelect={() => onSelectedChange([])}>
|
|
98
|
+
<DropdownMenuItem onSelect={() => onSelectedChange([])}>
|
|
99
|
+
{t("data.facetFilter.clearFilters")}
|
|
100
|
+
</DropdownMenuItem>
|
|
97
101
|
</>
|
|
98
102
|
) : null}
|
|
99
103
|
</DropdownMenuContent>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, fn, userEvent, within } from "storybook/test";
|
|
4
|
+
import { FilterChip } from "./filter-chip";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Data/FilterBar/FilterChip",
|
|
8
|
+
component: FilterChip,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: "padded",
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
"The removable active-filter chip for `FilterBar`, with an optional secondary " +
|
|
15
|
+
'count ("excluded 1,204"), passed to the base `trailing` slot (#284) rather than ' +
|
|
16
|
+
"folded into `label` — a long label truncates on its own, the count never loses " +
|
|
17
|
+
"characters to the ellipsis. Composes `@elabs-ai/components-ui`'s `FilterChip` — the " +
|
|
18
|
+
'whole chip is a single button whose accessible name is "Remove filter: <label> · ' +
|
|
19
|
+
'<trailing>" (WCAG 2.5.3), so the count still reaches the accessible name, not only ' +
|
|
20
|
+
"the visible text.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
args: {
|
|
25
|
+
label: "Status: Failed",
|
|
26
|
+
onRemove: fn(),
|
|
27
|
+
},
|
|
28
|
+
tags: ["autodocs"],
|
|
29
|
+
} satisfies Meta<typeof FilterChip>;
|
|
30
|
+
export default meta;
|
|
31
|
+
type Story = StoryObj<typeof meta>;
|
|
32
|
+
|
|
33
|
+
export const Default: Story = {
|
|
34
|
+
play: async ({ canvasElement, args }) => {
|
|
35
|
+
const canvas = within(canvasElement);
|
|
36
|
+
const chip = canvas.getByRole("button", { name: "Remove filter: Status: Failed" });
|
|
37
|
+
await userEvent.click(chip);
|
|
38
|
+
await expect(args.onRemove).toHaveBeenCalledTimes(1);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A count with a caller-supplied label — "excluded 1,204", locale-formatted. */
|
|
43
|
+
export const WithCount: Story = {
|
|
44
|
+
args: { count: 1204, countLabel: "excluded" },
|
|
45
|
+
play: async ({ canvasElement }) => {
|
|
46
|
+
const canvas = within(canvasElement);
|
|
47
|
+
// Label and count are separate elements (#284) — the count is never
|
|
48
|
+
// folded into the truncatable label string.
|
|
49
|
+
await expect(canvas.getByText("Status: Failed")).toBeInTheDocument();
|
|
50
|
+
await expect(canvas.getByText("excluded 1,204")).toBeInTheDocument();
|
|
51
|
+
await expect(
|
|
52
|
+
canvas.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
|
|
53
|
+
).toBeInTheDocument();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** A bare count with no `countLabel` — just the formatted number. */
|
|
58
|
+
export const BareCount: Story = {
|
|
59
|
+
args: { count: 1204 },
|
|
60
|
+
play: async ({ canvasElement }) => {
|
|
61
|
+
const canvas = within(canvasElement);
|
|
62
|
+
await expect(canvas.getByText("Status: Failed")).toBeInTheDocument();
|
|
63
|
+
await expect(canvas.getByText("1,204")).toBeInTheDocument();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Several chips in a run — removing one never touches its siblings. */
|
|
68
|
+
function MultipleChipsDemo() {
|
|
69
|
+
const [chips, setChips] = useState([
|
|
70
|
+
{ id: "status", label: "Status: Failed", count: 1204, countLabel: "excluded" },
|
|
71
|
+
{ id: "region", label: "Region: EU" },
|
|
72
|
+
]);
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
75
|
+
{chips.map((chip) => (
|
|
76
|
+
<FilterChip
|
|
77
|
+
key={chip.id}
|
|
78
|
+
label={chip.label}
|
|
79
|
+
count={chip.count}
|
|
80
|
+
countLabel={chip.countLabel}
|
|
81
|
+
onRemove={() => setChips((prev) => prev.filter((c) => c.id !== chip.id))}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const MultipleChips: Story = {
|
|
89
|
+
render: () => <MultipleChipsDemo />,
|
|
90
|
+
play: async ({ canvasElement }) => {
|
|
91
|
+
const canvas = within(canvasElement);
|
|
92
|
+
const statusChip = canvas.getByRole("button", {
|
|
93
|
+
name: "Remove filter: Status: Failed · excluded 1,204",
|
|
94
|
+
});
|
|
95
|
+
const regionChip = canvas.getByRole("button", { name: "Remove filter: Region: EU" });
|
|
96
|
+
await userEvent.click(statusChip);
|
|
97
|
+
await expect(statusChip).not.toBeInTheDocument();
|
|
98
|
+
await expect(regionChip).toBeInTheDocument();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A label long enough to overflow a 280px container (#284). The label
|
|
104
|
+
* truncates with an ellipsis; the count stays fully visible — CSS
|
|
105
|
+
* `text-overflow: ellipsis` can only reach the label's own span, never the
|
|
106
|
+
* count's sibling element.
|
|
107
|
+
*/
|
|
108
|
+
export const LongLabelWithCount: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
label: "Status: Awaiting downstream reconciliation review",
|
|
111
|
+
count: 1204,
|
|
112
|
+
countLabel: "excluded",
|
|
113
|
+
},
|
|
114
|
+
decorators: [
|
|
115
|
+
(Story) => (
|
|
116
|
+
<div className="w-[280px]">
|
|
117
|
+
<Story />
|
|
118
|
+
</div>
|
|
119
|
+
),
|
|
120
|
+
],
|
|
121
|
+
play: async ({ canvasElement }) => {
|
|
122
|
+
const canvas = within(canvasElement);
|
|
123
|
+
const count = await canvas.findByText("excluded 1,204");
|
|
124
|
+
await expect(count).toBeInTheDocument();
|
|
125
|
+
|
|
126
|
+
const truncating = canvasElement.querySelector('[class*="truncate"]');
|
|
127
|
+
await expect(truncating).not.toBeNull();
|
|
128
|
+
// The count must never be a descendant of the truncating label span.
|
|
129
|
+
await expect(truncating?.contains(count)).toBe(false);
|
|
130
|
+
|
|
131
|
+
await expect(
|
|
132
|
+
canvas.getByRole("button", {
|
|
133
|
+
name: "Remove filter: Status: Awaiting downstream reconciliation review · excluded 1,204",
|
|
134
|
+
}),
|
|
135
|
+
).toBeInTheDocument();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const WithCountHighDecoration: Story = {
|
|
140
|
+
name: "With count — high decoration",
|
|
141
|
+
globals: { decoration: "10" },
|
|
142
|
+
args: { count: 1204, countLabel: "excluded" },
|
|
143
|
+
play: WithCount.play,
|
|
144
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* filter-chip.test.tsx — smoke + count-in-accessible-name lock (#221).
|
|
3
|
+
*
|
|
4
|
+
* `FilterChip` composes `@elabs-ai/components-ui`'s FilterChip; the contract worth
|
|
5
|
+
* locking here is specific to the count feature this wrapper adds: the
|
|
6
|
+
* formatted count reaches the VISIBLE text and the chip's ACCESSIBLE NAME
|
|
7
|
+
* (WCAG 2.5.3), and removing one chip never touches its siblings.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { render, screen } from "@testing-library/react";
|
|
11
|
+
import userEvent from "@testing-library/user-event";
|
|
12
|
+
import { FilterChip } from "./filter-chip";
|
|
13
|
+
|
|
14
|
+
describe("FilterChip", () => {
|
|
15
|
+
it("renders the bare label with no count", () => {
|
|
16
|
+
render(<FilterChip label="Status: Failed" onRemove={vi.fn()} />);
|
|
17
|
+
expect(
|
|
18
|
+
screen.getByRole("button", { name: "Remove filter: Status: Failed" }),
|
|
19
|
+
).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByText("Status: Failed")).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders 'excluded 1,204' (locale-formatted) alongside the label", () => {
|
|
24
|
+
render(
|
|
25
|
+
<FilterChip label="Status: Failed" count={1204} countLabel="excluded" onRemove={vi.fn()} />,
|
|
26
|
+
);
|
|
27
|
+
expect(screen.getByText("Status: Failed")).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText("excluded 1,204")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("folds the count into the chip's ACCESSIBLE NAME, not only its visible text", () => {
|
|
32
|
+
render(
|
|
33
|
+
<FilterChip label="Status: Failed" count={1204} countLabel="excluded" onRemove={vi.fn()} />,
|
|
34
|
+
);
|
|
35
|
+
expect(
|
|
36
|
+
screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
|
|
37
|
+
).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders a bare formatted count with no countLabel", () => {
|
|
41
|
+
render(<FilterChip label="Status: Failed" count={1204} onRemove={vi.fn()} />);
|
|
42
|
+
expect(screen.getByText("Status: Failed")).toBeInTheDocument();
|
|
43
|
+
expect(screen.getByText("1,204")).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fires onRemove for the clicked chip only, leaving sibling chips untouched", async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const removeFirst = vi.fn();
|
|
49
|
+
const removeSecond = vi.fn();
|
|
50
|
+
render(
|
|
51
|
+
<>
|
|
52
|
+
<FilterChip label="Status: Failed" onRemove={removeFirst} />
|
|
53
|
+
<FilterChip label="Region: EU" onRemove={removeSecond} />
|
|
54
|
+
</>,
|
|
55
|
+
);
|
|
56
|
+
await user.click(screen.getByRole("button", { name: "Remove filter: Status: Failed" }));
|
|
57
|
+
expect(removeFirst).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(removeSecond).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("merges a caller className onto the root", () => {
|
|
62
|
+
render(<FilterChip label="Status: Failed" onRemove={vi.fn()} className="extra" />);
|
|
63
|
+
expect(screen.getByRole("button")).toHaveClass("extra");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keeps the count in a non-shrinking element so truncation can only reach the label", () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<FilterChip
|
|
69
|
+
label="Status: Awaiting downstream reconciliation review"
|
|
70
|
+
count={1204}
|
|
71
|
+
countLabel="excluded"
|
|
72
|
+
onRemove={vi.fn()}
|
|
73
|
+
/>,
|
|
74
|
+
);
|
|
75
|
+
const truncating = container.querySelector('[class*="truncate"]');
|
|
76
|
+
expect(truncating).not.toBeNull();
|
|
77
|
+
expect(truncating).toHaveTextContent("Status: Awaiting downstream reconciliation review");
|
|
78
|
+
expect(truncating?.textContent).toBe("Status: Awaiting downstream reconciliation review");
|
|
79
|
+
|
|
80
|
+
// The count lives in its own, sibling element — never inside the truncating one.
|
|
81
|
+
const count = screen.getByText("excluded 1,204");
|
|
82
|
+
expect(count).not.toBe(truncating);
|
|
83
|
+
expect(truncating?.contains(count)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Runtime lock (PR #408 review round 2): `Omit<BaseFilterChipProps, "trailing">`
|
|
87
|
+
// only stops a `trailing` prop written as an object LITERAL — TypeScript's
|
|
88
|
+
// excess-property check does not apply to a spread of an already-declared
|
|
89
|
+
// variable, so a typed caller can still get `trailing` into `props` this
|
|
90
|
+
// way and have it win at render. This must hold at runtime regardless of
|
|
91
|
+
// what the type system caught.
|
|
92
|
+
it("keeps the derived count even when a caller spreads a `trailing` override through a variable", () => {
|
|
93
|
+
const hijack = { trailing: "hijacked" } as { trailing: string };
|
|
94
|
+
render(
|
|
95
|
+
<FilterChip
|
|
96
|
+
label="Status: Failed"
|
|
97
|
+
count={1204}
|
|
98
|
+
countLabel="excluded"
|
|
99
|
+
onRemove={vi.fn()}
|
|
100
|
+
{...hijack}
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByText("excluded 1,204")).toBeInTheDocument();
|
|
104
|
+
expect(screen.queryByText("hijacked")).not.toBeInTheDocument();
|
|
105
|
+
expect(
|
|
106
|
+
screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
|
|
107
|
+
).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Type-level lock, same shape as ContextRail's `children` omission
|
|
111
|
+
// (context-rail.test.tsx #15): `FilterChipProps` is re-derived from the
|
|
112
|
+
// base package's `BaseFilterChipProps` and must omit `trailing` alongside
|
|
113
|
+
// `label`. The wrapper computes its OWN `trailing` from `count`/
|
|
114
|
+
// `countLabel` and strips any caller-supplied `trailing` from `props` at
|
|
115
|
+
// RUNTIME before forwarding to the base component, so a caller-supplied
|
|
116
|
+
// `trailing` — whether it type-checks as an object literal (it doesn't,
|
|
117
|
+
// thanks to this Omit) or slips through a spread of an already-declared
|
|
118
|
+
// variable (it does; see the runtime lock above) — can never win at
|
|
119
|
+
// render. Fails to typecheck the moment `trailing` is dropped from the
|
|
120
|
+
// `Omit`.
|
|
121
|
+
it("(type-level) does not accept a `trailing` prop", () => {
|
|
122
|
+
function typeOnly() {
|
|
123
|
+
return (
|
|
124
|
+
<FilterChip
|
|
125
|
+
label="Status: Failed"
|
|
126
|
+
count={1204}
|
|
127
|
+
// @ts-expect-error — `trailing` is omitted from `FilterChipProps`;
|
|
128
|
+
// the wrapper derives its own `trailing` from `count`/`countLabel`
|
|
129
|
+
// and must not let a caller override it.
|
|
130
|
+
trailing="hijacked"
|
|
131
|
+
onRemove={vi.fn()}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
expect(typeof typeOnly).toBe("function");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* filter-chip.tsx — `@elabs-ai/components-data`'s removable filter chip, with an optional
|
|
5
|
+
* secondary count ("excluded 1,204") for `ProcessFilterBar` (RM-056, #221).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a thin COMPOSING wrapper around `@elabs-ai/components-ui`'s `FilterChip`
|
|
8
|
+
* (`view-toolbar.tsx`, #331) rather than a second implementation — the dedupe
|
|
9
|
+
* audit found the real, accessible, whole-chip-as-button `FilterChip` already
|
|
10
|
+
* lives there (WCAG 2.5.8 target size, WCAG 2.5.3 "Remove filter: <label>"
|
|
11
|
+
* accessible name). Building a second one in `packages/data` would duplicate
|
|
12
|
+
* that work; this wrapper reuses it and passes `count`/`countLabel` through
|
|
13
|
+
* the base component's `trailing` slot (#284) — a second, non-shrinking text
|
|
14
|
+
* element, distinct from the truncatable `label` — so the count reaches the
|
|
15
|
+
* chip's ACCESSIBLE NAME (screen readers hear "Remove filter: Status: Failed
|
|
16
|
+
* · excluded 1,204") AND survives truncation in the visible chip, instead of
|
|
17
|
+
* being folded into the one string CSS `truncate` can clip from the tail.
|
|
18
|
+
*/
|
|
19
|
+
import { forwardRef } from "react";
|
|
20
|
+
import {
|
|
21
|
+
FilterChip as BaseFilterChip,
|
|
22
|
+
type FilterChipProps as BaseFilterChipProps,
|
|
23
|
+
useLocale,
|
|
24
|
+
} from "@elabs-ai/components-ui";
|
|
25
|
+
|
|
26
|
+
// `trailing` is omitted alongside `label`: this wrapper derives its OWN
|
|
27
|
+
// `trailing` from `count`/`countLabel`. The `Omit` blocks `trailing` written
|
|
28
|
+
// as an object LITERAL, but TypeScript's excess-property check does not
|
|
29
|
+
// apply to a spread of an already-declared variable — `const extra = {
|
|
30
|
+
// trailing: "x" }; <FilterChip {...extra} />` still type-checks, and the
|
|
31
|
+
// value would land in `props` regardless of JSX attribute order (PR #408
|
|
32
|
+
// review round 2). So the `Omit` is necessary but not sufficient: below,
|
|
33
|
+
// `trailing` is also stripped from `props` at RUNTIME before it reaches the
|
|
34
|
+
// base component, so a caller-supplied `trailing` — literal or
|
|
35
|
+
// spread-smuggled — can never win at render, the same advertised-but-inert
|
|
36
|
+
// failure mode #382/#284-round-1 already closed elsewhere in the repo
|
|
37
|
+
// (`ContextRail`'s `children` omission).
|
|
38
|
+
export interface FilterChipProps extends Omit<BaseFilterChipProps, "label" | "trailing"> {
|
|
39
|
+
/**
|
|
40
|
+
* Label-in-value text — `"Status: Failed"`, never `"Status = failed"` and
|
|
41
|
+
* never a bare `"Failed"`. Same contract as the base `FilterChip`.
|
|
42
|
+
*/
|
|
43
|
+
label: string;
|
|
44
|
+
/**
|
|
45
|
+
* How many records this active filter excluded (or matched) — rendered as a
|
|
46
|
+
* secondary, locale-formatted segment alongside `label`. Omit for a bare
|
|
47
|
+
* chip with no count.
|
|
48
|
+
*/
|
|
49
|
+
count?: number;
|
|
50
|
+
/**
|
|
51
|
+
* The word placed before the formatted count, e.g. `"excluded"` →
|
|
52
|
+
* `"excluded 1,204"`. Omitted by default: a bare `count` renders as just the
|
|
53
|
+
* formatted number.
|
|
54
|
+
*/
|
|
55
|
+
countLabel?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A removable active-filter chip with an optional secondary count.
|
|
60
|
+
*
|
|
61
|
+
* `onRemove` stays REQUIRED (inherited from the base `FilterChip`, diverging
|
|
62
|
+
* from this item's spec draft) — the whole chip IS the remove control, so a
|
|
63
|
+
* chip with no removal affordance is a plain `Badge`, not this component.
|
|
64
|
+
*/
|
|
65
|
+
export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(function FilterChip(
|
|
66
|
+
{ label, count, countLabel, ...props },
|
|
67
|
+
ref,
|
|
68
|
+
) {
|
|
69
|
+
const { formatNumber } = useLocale();
|
|
70
|
+
const countText =
|
|
71
|
+
count === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: countLabel
|
|
74
|
+
? `${countLabel} ${formatNumber(count)}`
|
|
75
|
+
: formatNumber(count);
|
|
76
|
+
|
|
77
|
+
// Runtime guard (belt and braces alongside the `Omit` above): a caller can
|
|
78
|
+
// still smuggle `trailing` into `props` through a spread of an
|
|
79
|
+
// already-declared variable, which the type system cannot catch. Strip it
|
|
80
|
+
// here so the derived count wins regardless of prop order.
|
|
81
|
+
const { trailing: _ignoredTrailing, ...restProps } = props as Omit<BaseFilterChipProps, "label">;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<BaseFilterChip
|
|
85
|
+
ref={ref}
|
|
86
|
+
data-slot="filter-chip"
|
|
87
|
+
label={label}
|
|
88
|
+
{...restProps}
|
|
89
|
+
trailing={countText}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
});
|
package/src/filter-bar/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -12,7 +12,16 @@ export * from "./facet-filter";
|
|
|
12
12
|
export * from "./column-picker";
|
|
13
13
|
|
|
14
14
|
// Re-export the most common TanStack types so consumers don't need a direct dep.
|
|
15
|
-
export type {
|
|
15
|
+
export type {
|
|
16
|
+
ColumnDef,
|
|
17
|
+
ColumnMeta,
|
|
18
|
+
ColumnPinningState,
|
|
19
|
+
ColumnSizingState,
|
|
20
|
+
RowSelectionState,
|
|
21
|
+
Table,
|
|
22
|
+
Row,
|
|
23
|
+
CellContext,
|
|
24
|
+
} from "@tanstack/react-table";
|
|
16
25
|
|
|
17
26
|
// CSV helpers — pure, dependency-free serializer + browser download trigger.
|
|
18
27
|
export * from "./to-csv";
|
|
@@ -10,6 +10,9 @@ const meta = {
|
|
|
10
10
|
docs: {
|
|
11
11
|
description: {
|
|
12
12
|
component:
|
|
13
|
+
"The SEARCH field, with a leading icon and a clear button; a plain text field is " +
|
|
14
|
+
"`Core/Input` — see " +
|
|
15
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
13
16
|
"Controlled search field with a leading icon and a clear button. Pair it with " +
|
|
14
17
|
"`FilterBar` and drive a DataTable's global filter from the `toolbar` render-prop. " +
|
|
15
18
|
"The label is visually hidden but real — the placeholder is never the accessible name.",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* `<input>` (no placeholder-as-label), every keystroke reported to the caller,
|
|
8
8
|
* and a named clear affordance that only exists when there is something to clear.
|
|
9
9
|
*/
|
|
10
|
+
import { createRef, useState } from "react";
|
|
10
11
|
import { describe, expect, it, vi } from "vitest";
|
|
11
12
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
12
13
|
import { SearchInput } from "./search-input";
|
|
@@ -29,6 +30,26 @@ describe("SearchInput — accessible name", () => {
|
|
|
29
30
|
expect(label).not.toBeNull();
|
|
30
31
|
expect(label).toHaveAttribute("for", input.getAttribute("id"));
|
|
31
32
|
});
|
|
33
|
+
|
|
34
|
+
it("keeps the label wired to a consumer-supplied id instead of a stale generated one", () => {
|
|
35
|
+
render(<SearchInput value="" onValueChange={vi.fn()} id="deployment-search" />);
|
|
36
|
+
const input = screen.getByRole("textbox", { name: "Search" });
|
|
37
|
+
// The consumer's own `id` used to reach the <input> (via the trailing
|
|
38
|
+
// `...props` spread) while the <label>'s `htmlFor` kept pointing at an
|
|
39
|
+
// internally-generated id that no element actually had — breaking the
|
|
40
|
+
// label/input association `getByRole({ name })` above depends on.
|
|
41
|
+
expect(input).toHaveAttribute("id", "deployment-search");
|
|
42
|
+
expect(document.querySelector("label")).toHaveAttribute("for", "deployment-search");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("SearchInput — ref", () => {
|
|
47
|
+
it("forwards a ref to the underlying <input>", () => {
|
|
48
|
+
const ref = createRef<HTMLInputElement>();
|
|
49
|
+
render(<SearchInput ref={ref} value="" onValueChange={vi.fn()} />);
|
|
50
|
+
expect(ref.current).toBeInstanceOf(HTMLInputElement);
|
|
51
|
+
expect(ref.current).toBe(screen.getByRole("textbox", { name: "Search" }));
|
|
52
|
+
});
|
|
32
53
|
});
|
|
33
54
|
|
|
34
55
|
describe("SearchInput — value reporting", () => {
|
|
@@ -72,6 +93,20 @@ describe("SearchInput — clear affordance", () => {
|
|
|
72
93
|
const svg = screen.getByRole("button", { name: "Clear search" }).querySelector("svg");
|
|
73
94
|
expect(svg).toHaveAttribute("aria-hidden", "true");
|
|
74
95
|
});
|
|
96
|
+
|
|
97
|
+
it("returns keyboard focus to the input after clearing (the button itself unmounts)", () => {
|
|
98
|
+
function Harness() {
|
|
99
|
+
const [value, setValue] = useState("billing");
|
|
100
|
+
return <SearchInput value={value} onValueChange={setValue} />;
|
|
101
|
+
}
|
|
102
|
+
render(<Harness />);
|
|
103
|
+
const clearButton = screen.getByRole("button", { name: "Clear search" });
|
|
104
|
+
clearButton.focus();
|
|
105
|
+
fireEvent.click(clearButton);
|
|
106
|
+
// The clear button is conditionally rendered on `value` — once it
|
|
107
|
+
// disappears, a browser drops focus to <body> unless something claims it.
|
|
108
|
+
expect(screen.getByRole("textbox", { name: "Search" })).toHaveFocus();
|
|
109
|
+
});
|
|
75
110
|
});
|
|
76
111
|
|
|
77
112
|
describe("SearchInput — composability", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useId, type InputHTMLAttributes } from "react";
|
|
4
|
-
import { Input } from "@elabs-ai/components-ui";
|
|
3
|
+
import { forwardRef, useId, useRef, type InputHTMLAttributes } from "react";
|
|
4
|
+
import { Input, useLocale } from "@elabs-ai/components-ui";
|
|
5
5
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
6
|
import { SearchIcon } from "@elabs-ai/components-icons";
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ export interface SearchInputProps extends Omit<
|
|
|
11
11
|
> {
|
|
12
12
|
value: string;
|
|
13
13
|
onValueChange: (value: string) => void;
|
|
14
|
-
/** Visually-hidden accessible label. Defaults to "Search". */
|
|
14
|
+
/** Visually-hidden accessible label. Defaults to the localized "Search" microcopy. */
|
|
15
15
|
label?: string;
|
|
16
16
|
containerClassName?: string;
|
|
17
17
|
}
|
|
@@ -25,31 +25,56 @@ export interface SearchInputProps extends Omit<
|
|
|
25
25
|
* `<Input>` explicitly AND gates the clear button — while disabled the clear
|
|
26
26
|
* affordance is hidden so it can't mutate the filter mid-request (#269/#8).
|
|
27
27
|
*/
|
|
28
|
-
export function SearchInput(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(function SearchInput(
|
|
29
|
+
{
|
|
30
|
+
value,
|
|
31
|
+
onValueChange,
|
|
32
|
+
label,
|
|
33
|
+
placeholder,
|
|
34
|
+
className,
|
|
35
|
+
containerClassName,
|
|
36
|
+
disabled,
|
|
37
|
+
id: idProp,
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
forwardedRef,
|
|
41
|
+
) {
|
|
42
|
+
const { t } = useLocale();
|
|
43
|
+
const resolvedLabel = label ?? t("data.searchInput.label");
|
|
44
|
+
const resolvedPlaceholder = placeholder ?? t("data.searchInput.placeholder");
|
|
45
|
+
const generatedId = useId();
|
|
46
|
+
const id = idProp ?? generatedId;
|
|
47
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
|
|
49
|
+
const setRefs = (node: HTMLInputElement | null) => {
|
|
50
|
+
inputRef.current = node;
|
|
51
|
+
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
52
|
+
else if (forwardedRef) forwardedRef.current = node;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleClear = () => {
|
|
56
|
+
onValueChange("");
|
|
57
|
+
// The clear button unmounts the instant `value` becomes falsy — without
|
|
58
|
+
// this, the browser drops focus to <body> instead of leaving it
|
|
59
|
+
// somewhere the keyboard user can keep typing.
|
|
60
|
+
inputRef.current?.focus();
|
|
61
|
+
};
|
|
62
|
+
|
|
39
63
|
return (
|
|
40
64
|
<div className={cn("relative w-full max-w-xs", containerClassName)}>
|
|
41
65
|
<label htmlFor={id} className="sr-only">
|
|
42
|
-
{
|
|
66
|
+
{resolvedLabel}
|
|
43
67
|
</label>
|
|
44
68
|
<SearchIcon
|
|
45
69
|
size={16}
|
|
46
70
|
className="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
47
71
|
/>
|
|
48
72
|
<Input
|
|
73
|
+
ref={setRefs}
|
|
49
74
|
id={id}
|
|
50
75
|
value={value}
|
|
51
76
|
onChange={(e) => onValueChange(e.target.value)}
|
|
52
|
-
placeholder={
|
|
77
|
+
placeholder={resolvedPlaceholder}
|
|
53
78
|
disabled={disabled}
|
|
54
79
|
className={cn("ps-8", value && "pe-8", className)}
|
|
55
80
|
{...props}
|
|
@@ -57,9 +82,9 @@ export function SearchInput({
|
|
|
57
82
|
{value && !disabled ? (
|
|
58
83
|
<button
|
|
59
84
|
type="button"
|
|
60
|
-
onClick={
|
|
61
|
-
aria-label="
|
|
62
|
-
className="absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground focus-
|
|
85
|
+
onClick={handleClear}
|
|
86
|
+
aria-label={t("data.searchInput.clear")}
|
|
87
|
+
className="absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground focus-ring animate-in fade-in zoom-in-95 duration-fast ease-entrance"
|
|
63
88
|
>
|
|
64
89
|
<svg
|
|
65
90
|
width="14"
|
|
@@ -78,4 +103,4 @@ export function SearchInput({
|
|
|
78
103
|
) : null}
|
|
79
104
|
</div>
|
|
80
105
|
);
|
|
81
|
-
}
|
|
106
|
+
});
|