@elabs-ai/components-data 4.0.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.
@@ -0,0 +1,7 @@
1
+ export {
2
+ DataTable,
3
+ type DataTableProps,
4
+ type DataTableViewState,
5
+ type DataTableServerArgs,
6
+ type DataTableRowClickHandler,
7
+ } from "./data-table";
@@ -0,0 +1,121 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, waitFor } from "storybook/test";
4
+ import {
5
+ Button,
6
+ DatePicker,
7
+ Input,
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@elabs-ai/components-ui";
14
+ import { FacetFilter } from "./facet-filter";
15
+
16
+ const options = [
17
+ { label: "Healthy", value: "healthy" },
18
+ { label: "Degraded", value: "degraded" },
19
+ { label: "Down", value: "down" },
20
+ ];
21
+
22
+ const meta = {
23
+ title: "Data/FacetFilter",
24
+ component: FacetFilter,
25
+ parameters: {
26
+ layout: "padded",
27
+ docs: {
28
+ description: {
29
+ component:
30
+ "Multi-select faceted filter rendered as a dropdown of toggles. Controlled — it emits " +
31
+ "the next selection and never holds its own state, so the app (or a DataTable " +
32
+ "`columnFilters` slice) stays the single source of truth.",
33
+ },
34
+ },
35
+ },
36
+ tags: ["autodocs"],
37
+ } satisfies Meta<typeof FacetFilter>;
38
+ export default meta;
39
+ type Story = StoryObj<typeof meta>;
40
+
41
+ function Controlled({ initial = [] as string[] }) {
42
+ const [selected, setSelected] = useState<string[]>(initial);
43
+ return (
44
+ <FacetFilter
45
+ title="Status"
46
+ options={options}
47
+ selected={selected}
48
+ onSelectedChange={setSelected}
49
+ />
50
+ );
51
+ }
52
+
53
+ export const Default: Story = {
54
+ args: { title: "Status", options, selected: [], onSelectedChange: () => {} },
55
+ render: () => <Controlled />,
56
+ };
57
+
58
+ /** With a selection the trigger carries a count badge and the menu gains "Clear filters". */
59
+ export const WithSelection: Story = {
60
+ args: { title: "Status", options, selected: ["degraded"], onSelectedChange: () => {} },
61
+ render: () => <Controlled initial={["degraded", "down"]} />,
62
+ };
63
+
64
+ /**
65
+ * #346 — the filter row a `FacetFilter` actually lives in. Its trigger used to
66
+ * take `Button size="sm"` (`h-8`) while every sibling control lands on `h-9`
67
+ * (`Select`'s default rung, `Input`'s hardcoded height, `DatePicker` and a plain
68
+ * `Button` via `Button`'s own default), so the row's top and bottom edges didn't
69
+ * line up. The trigger now inherits that same default.
70
+ *
71
+ * The play function is the acceptance test: it MEASURES every control's rendered
72
+ * box in a real browser (`getBoundingClientRect`) and asserts one height and one
73
+ * top edge across all five. A class-list assertion would only restate the source.
74
+ */
75
+ export const ToolbarAlignment: Story = {
76
+ args: { title: "Status", options, selected: [], onSelectedChange: () => {} },
77
+ parameters: {
78
+ docs: {
79
+ description: {
80
+ story:
81
+ "FacetFilter beside Select, DatePicker, Input and Button — all five controls resolve " +
82
+ "to the same height and baseline (#346).",
83
+ },
84
+ },
85
+ },
86
+ render: () => (
87
+ <div data-testid="filter-row" className="flex flex-wrap items-center gap-2">
88
+ <Controlled initial={["degraded"]} />
89
+ <Select defaultValue="prod">
90
+ <SelectTrigger className="w-36" aria-label="Environment">
91
+ <SelectValue placeholder="Environment" />
92
+ </SelectTrigger>
93
+ <SelectContent>
94
+ <SelectItem value="prod">Production</SelectItem>
95
+ <SelectItem value="staging">Staging</SelectItem>
96
+ </SelectContent>
97
+ </Select>
98
+ <DatePicker placeholder="Pick a date" />
99
+ <Input className="w-40" aria-label="Search services" placeholder="e.g. billing…" />
100
+ <Button variant="outline">Reset</Button>
101
+ </div>
102
+ ),
103
+ play: async ({ canvas }) => {
104
+ const row = canvas.getByTestId("filter-row");
105
+ // Direct children only: the row's five controls, in DOM order.
106
+ const controls = Array.from(row.children) as HTMLElement[];
107
+ await expect(controls).toHaveLength(5);
108
+
109
+ await waitFor(() => {
110
+ const boxes = controls.map((el) => el.getBoundingClientRect());
111
+ // A real layout pass must have happened before the comparison means anything.
112
+ expect(boxes[0]!.height).toBeGreaterThan(0);
113
+ for (const box of boxes) {
114
+ // Sub-pixel rounding tolerance; anything larger is a genuine mismatch
115
+ // (the h-8/h-9 delta this story locks was a full 4px).
116
+ expect(Math.abs(box.height - boxes[0]!.height)).toBeLessThan(0.5);
117
+ expect(Math.abs(box.top - boxes[0]!.top)).toBeLessThan(0.5);
118
+ }
119
+ });
120
+ },
121
+ };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * facet-filter.test.tsx — smoke + toggle-semantics lock for the faceted filter (#59).
3
+ *
4
+ * The behaviour that matters (and that no test covered before #59) is the
5
+ * multi-select toggle contract: selecting emits the union, re-selecting the same
6
+ * option emits the difference, and "Clear filters" only exists while something
7
+ * is selected. It is a CONTROLLED component — it must never hold its own
8
+ * selection state, so every assertion here is about what it EMITS.
9
+ *
10
+ * The menu is opened with a keyboard event on purpose: it is both the real
11
+ * keyboard path (WCAG 2.1.1) and the one jsdom models faithfully — jsdom has no
12
+ * pointer-capture, so a synthetic pointerdown would prove less, not more.
13
+ */
14
+ import { describe, expect, it, vi } from "vitest";
15
+ import { render, screen, fireEvent } from "@testing-library/react";
16
+ import { FacetFilter } from "./facet-filter";
17
+
18
+ const options = [
19
+ { label: "Healthy", value: "healthy" },
20
+ { label: "Degraded", value: "degraded" },
21
+ { label: "Down", value: "down" },
22
+ ];
23
+
24
+ /** Open the dropdown from the keyboard and return its trigger. */
25
+ function open(name = "Status") {
26
+ const trigger = screen.getByRole("button", { name: new RegExp(`^${name}`) });
27
+ fireEvent.keyDown(trigger, { key: "Enter" });
28
+ return trigger;
29
+ }
30
+
31
+ describe("FacetFilter — trigger", () => {
32
+ it("names the trigger with the facet title", () => {
33
+ render(
34
+ <FacetFilter title="Status" options={options} selected={[]} onSelectedChange={vi.fn()} />,
35
+ );
36
+ expect(screen.getByRole("button", { name: "Status" })).toBeInTheDocument();
37
+ });
38
+
39
+ it("shows the selected count on the trigger once a facet is active", () => {
40
+ render(
41
+ <FacetFilter
42
+ title="Status"
43
+ options={options}
44
+ selected={["healthy", "down"]}
45
+ onSelectedChange={vi.fn()}
46
+ />,
47
+ );
48
+ expect(screen.getByRole("button", { name: /^Status/ })).toHaveTextContent("2");
49
+ });
50
+
51
+ it("shows no count badge while nothing is selected", () => {
52
+ render(
53
+ <FacetFilter title="Status" options={options} selected={[]} onSelectedChange={vi.fn()} />,
54
+ );
55
+ expect(screen.getByRole("button", { name: "Status" }).textContent).toBe("Status");
56
+ });
57
+
58
+ /**
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-9) lines up.
61
+ * jsdom applies no Tailwind, so the class rung is what is assertable here; the
62
+ * MEASURED proof lives in the `ToolbarAlignment` story's play function, which
63
+ * compares real `getBoundingClientRect()` boxes in a browser.
64
+ */
65
+ it("renders the trigger at the shared default control height, not the sm rung", () => {
66
+ render(
67
+ <FacetFilter title="Status" options={options} selected={[]} onSelectedChange={vi.fn()} />,
68
+ );
69
+ const trigger = screen.getByRole("button", { name: "Status" });
70
+ expect(trigger).toHaveClass("h-9");
71
+ expect(trigger).not.toHaveClass("h-8");
72
+ });
73
+ });
74
+
75
+ describe("FacetFilter — menu contents", () => {
76
+ it("renders one menu item per option when opened", () => {
77
+ render(
78
+ <FacetFilter title="Status" options={options} selected={[]} onSelectedChange={vi.fn()} />,
79
+ );
80
+ open();
81
+ for (const opt of options) {
82
+ expect(screen.getByRole("menuitem", { name: opt.label })).toBeInTheDocument();
83
+ }
84
+ });
85
+
86
+ it("hides the decorative check box from assistive tech", () => {
87
+ render(
88
+ <FacetFilter
89
+ title="Status"
90
+ options={options}
91
+ selected={["healthy"]}
92
+ onSelectedChange={vi.fn()}
93
+ />,
94
+ );
95
+ open();
96
+ // The ✓ swatch is a visual affordance; the item's own name carries meaning.
97
+ const item = screen.getByRole("menuitem", { name: "Healthy" });
98
+ expect(item.querySelector("[aria-hidden='true']")).not.toBeNull();
99
+ });
100
+ });
101
+
102
+ describe("FacetFilter — controlled toggle semantics", () => {
103
+ it("adds a value to the selection when an unselected option is chosen", () => {
104
+ const onSelectedChange = vi.fn();
105
+ render(
106
+ <FacetFilter
107
+ title="Status"
108
+ options={options}
109
+ selected={["healthy"]}
110
+ onSelectedChange={onSelectedChange}
111
+ />,
112
+ );
113
+ open();
114
+ fireEvent.click(screen.getByRole("menuitem", { name: "Down" }));
115
+ expect(onSelectedChange).toHaveBeenCalledWith(["healthy", "down"]);
116
+ });
117
+
118
+ it("removes a value when an already-selected option is chosen again", () => {
119
+ const onSelectedChange = vi.fn();
120
+ render(
121
+ <FacetFilter
122
+ title="Status"
123
+ options={options}
124
+ selected={["healthy", "down"]}
125
+ onSelectedChange={onSelectedChange}
126
+ />,
127
+ );
128
+ open();
129
+ fireEvent.click(screen.getByRole("menuitem", { name: "Healthy" }));
130
+ expect(onSelectedChange).toHaveBeenCalledWith(["down"]);
131
+ });
132
+
133
+ it("does NOT manage its own selection — the trigger still reflects the prop after a toggle", () => {
134
+ const onSelectedChange = vi.fn();
135
+ render(
136
+ <FacetFilter
137
+ title="Status"
138
+ options={options}
139
+ selected={[]}
140
+ onSelectedChange={onSelectedChange}
141
+ />,
142
+ );
143
+ // Hold onto the trigger: while the menu is open Radix marks the rest of the
144
+ // tree aria-hidden, so a role query would no longer reach it.
145
+ const trigger = open();
146
+ fireEvent.click(screen.getByRole("menuitem", { name: "Down" }));
147
+ // The parent owns the state; with the prop unchanged there is still no badge.
148
+ expect(trigger.textContent).toBe("Status");
149
+ });
150
+ });
151
+
152
+ describe("FacetFilter — clear affordance", () => {
153
+ it("offers no Clear entry while nothing is selected", () => {
154
+ render(
155
+ <FacetFilter title="Status" options={options} selected={[]} onSelectedChange={vi.fn()} />,
156
+ );
157
+ open();
158
+ expect(screen.queryByRole("menuitem", { name: "Clear filters" })).toBeNull();
159
+ });
160
+
161
+ it("emits an empty selection from the Clear entry", () => {
162
+ const onSelectedChange = vi.fn();
163
+ render(
164
+ <FacetFilter
165
+ title="Status"
166
+ options={options}
167
+ selected={["healthy", "down"]}
168
+ onSelectedChange={onSelectedChange}
169
+ />,
170
+ );
171
+ open();
172
+ fireEvent.click(screen.getByRole("menuitem", { name: "Clear filters" }));
173
+ expect(onSelectedChange).toHaveBeenCalledWith([]);
174
+ });
175
+ });
@@ -0,0 +1,104 @@
1
+ import type { ButtonHTMLAttributes } from "react";
2
+ import { forwardRef } from "react";
3
+ import {
4
+ Badge,
5
+ Button,
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from "@elabs-ai/components-ui";
13
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
14
+
15
+ export interface FacetOption {
16
+ label: string;
17
+ value: string;
18
+ }
19
+
20
+ export interface FacetFilterProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "title"> {
21
+ title: string;
22
+ options: FacetOption[];
23
+ /** Currently selected values (controlled). */
24
+ selected: string[];
25
+ onSelectedChange: (values: string[]) => void;
26
+ }
27
+
28
+ /**
29
+ * Multi-select faceted filter rendered as a dropdown of toggles.
30
+ *
31
+ * `disabled` (forwarded to the trigger `Button`) is how a consumer signals a
32
+ * pending fetch (D5 — the app owns fetch state, this control just reflects
33
+ * it; see loading-states.md).
34
+ *
35
+ * The trigger takes `Button`'s DEFAULT size (`h-9`), not `sm` (#346): a facet
36
+ * filter lives in a toolbar beside `Select` / `Input` / `DatePicker`, all of
37
+ * which land on `h-9` (Select's own default rung, Input hardcoded, DatePicker
38
+ * via this same Button default). An `sm` trigger was the lone `h-8` outlier in
39
+ * that row, so the top and bottom edges of a filter bar didn't line up.
40
+ */
41
+ export const FacetFilter = forwardRef<HTMLButtonElement, FacetFilterProps>(function FacetFilter(
42
+ { title, options, selected, onSelectedChange, className, ...props },
43
+ ref,
44
+ ) {
45
+ const selectedSet = new Set(selected);
46
+ const toggle = (value: string) => {
47
+ const next = new Set(selectedSet);
48
+ if (next.has(value)) next.delete(value);
49
+ else next.add(value);
50
+ onSelectedChange([...next]);
51
+ };
52
+
53
+ return (
54
+ <DropdownMenu>
55
+ <DropdownMenuTrigger asChild>
56
+ <Button ref={ref} variant="outline" className={cn("border-dashed", className)} {...props}>
57
+ {title}
58
+ {selected.length > 0 ? (
59
+ <Badge
60
+ variant="secondary"
61
+ className="ms-1 rounded px-1.5 animate-in fade-in zoom-in-95 duration-fast ease-entrance"
62
+ >
63
+ {selected.length}
64
+ </Badge>
65
+ ) : null}
66
+ </Button>
67
+ </DropdownMenuTrigger>
68
+ <DropdownMenuContent className="min-w-[12rem]">
69
+ <DropdownMenuLabel>{title}</DropdownMenuLabel>
70
+ {options.map((opt) => {
71
+ const checked = selectedSet.has(opt.value);
72
+ return (
73
+ <DropdownMenuItem
74
+ key={opt.value}
75
+ onSelect={(e) => {
76
+ e.preventDefault();
77
+ toggle(opt.value);
78
+ }}
79
+ >
80
+ <span
81
+ aria-hidden="true"
82
+ className={
83
+ "flex size-4 items-center justify-center rounded border transition-colors duration-fast ease-standard " +
84
+ (checked ? "border-primary bg-primary text-primary-foreground" : "border-input")
85
+ }
86
+ >
87
+ {checked ? "✓" : ""}
88
+ </span>
89
+ {opt.label}
90
+ </DropdownMenuItem>
91
+ );
92
+ })}
93
+ {selected.length > 0 ? (
94
+ <>
95
+ <DropdownMenuSeparator />
96
+ <DropdownMenuItem onSelect={() => onSelectedChange([])}>Clear filters</DropdownMenuItem>
97
+ </>
98
+ ) : null}
99
+ </DropdownMenuContent>
100
+ </DropdownMenu>
101
+ );
102
+ });
103
+
104
+ FacetFilter.displayName = "FacetFilter";
@@ -0,0 +1 @@
1
+ export { FacetFilter, type FacetFilterProps, type FacetOption } from "./facet-filter";
@@ -0,0 +1,58 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { Button } from "@elabs-ai/components-ui";
4
+ import { FilterBar } from "./filter-bar";
5
+ import { SearchInput } from "../search-input";
6
+ import { FacetFilter } from "../facet-filter";
7
+
8
+ const statusOptions = [
9
+ { label: "Healthy", value: "healthy" },
10
+ { label: "Degraded", value: "degraded" },
11
+ { label: "Down", value: "down" },
12
+ ];
13
+
14
+ const meta = {
15
+ title: "Data/FilterBar",
16
+ component: FilterBar,
17
+ parameters: {
18
+ layout: "padded",
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "The two-cluster table toolbar: filters on the leading edge, actions on the trailing " +
23
+ "edge. It owns no state — compose it from `SearchInput`, `FacetFilter` and " +
24
+ "`ColumnPicker` and pass it to a DataTable's `toolbar` render-prop.",
25
+ },
26
+ },
27
+ },
28
+ tags: ["autodocs"],
29
+ } satisfies Meta<typeof FilterBar>;
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ function Toolbar({ withActions = true }: { withActions?: boolean }) {
34
+ const [query, setQuery] = useState("");
35
+ const [status, setStatus] = useState<string[]>([]);
36
+ return (
37
+ <FilterBar actions={withActions ? <Button size="sm">Export CSV</Button> : undefined}>
38
+ <SearchInput value={query} onValueChange={setQuery} />
39
+ <FacetFilter
40
+ title="Status"
41
+ options={statusOptions}
42
+ selected={status}
43
+ onSelectedChange={setStatus}
44
+ />
45
+ </FilterBar>
46
+ );
47
+ }
48
+
49
+ export const Default: Story = {
50
+ args: { children: null },
51
+ render: () => <Toolbar />,
52
+ };
53
+
54
+ /** Without `actions` the trailing cluster is omitted entirely, not rendered empty. */
55
+ export const FiltersOnly: Story = {
56
+ args: { children: null },
57
+ render: () => <Toolbar withActions={false} />,
58
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * filter-bar.test.tsx — smoke + layout-contract lock for the table toolbar (#59).
3
+ *
4
+ * FilterBar is deliberately thin: it is the two-cluster toolbar grammar (filters
5
+ * on the leading edge, actions on the trailing edge) that every data screen
6
+ * repeats. The contract worth locking is that both clusters exist, that the
7
+ * actions cluster is OMITTED (not rendered empty) when there are no actions —
8
+ * an empty flex box would still eat the `gap` — and that it composes.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { render, screen } from "@testing-library/react";
12
+ import { FilterBar } from "./filter-bar";
13
+
14
+ describe("FilterBar", () => {
15
+ it("renders its filter children", () => {
16
+ render(
17
+ <FilterBar>
18
+ <button type="button">Status</button>
19
+ </FilterBar>,
20
+ );
21
+ expect(screen.getByRole("button", { name: "Status" })).toBeInTheDocument();
22
+ });
23
+
24
+ it("renders the actions cluster when actions are supplied", () => {
25
+ render(
26
+ <FilterBar actions={<button type="button">Export</button>}>
27
+ <button type="button">Status</button>
28
+ </FilterBar>,
29
+ );
30
+ expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
31
+ });
32
+
33
+ it("keeps filters and actions in SEPARATE clusters (leading vs trailing)", () => {
34
+ const { container } = render(
35
+ <FilterBar actions={<button type="button">Export</button>}>
36
+ <button type="button">Status</button>
37
+ </FilterBar>,
38
+ );
39
+ const clusters = container.firstElementChild?.children;
40
+ expect(clusters).toHaveLength(2);
41
+ expect(clusters?.[0]).toHaveTextContent("Status");
42
+ expect(clusters?.[1]).toHaveTextContent("Export");
43
+ });
44
+
45
+ it("omits the actions cluster entirely when there are no actions", () => {
46
+ const { container } = render(
47
+ <FilterBar>
48
+ <button type="button">Status</button>
49
+ </FilterBar>,
50
+ );
51
+ // One child only — an empty second flex row would still consume the gap.
52
+ expect(container.firstElementChild?.children).toHaveLength(1);
53
+ });
54
+
55
+ it("merges a caller className onto the root without dropping the layout classes", () => {
56
+ const { container } = render(<FilterBar className="extra">x</FilterBar>);
57
+ expect(container.firstChild).toHaveClass("extra");
58
+ expect(container.firstChild).toHaveClass("flex");
59
+ });
60
+ });
@@ -0,0 +1,20 @@
1
+ import { type ReactNode } from "react";
2
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
3
+
4
+ export interface FilterBarProps {
5
+ /** Left cluster: search + facet filters. */
6
+ children: ReactNode;
7
+ /** Right cluster: column picker, export, primary actions. */
8
+ actions?: ReactNode;
9
+ className?: string;
10
+ }
11
+
12
+ /** Horizontal toolbar that groups table filters and actions. */
13
+ export function FilterBar({ children, actions, className }: FilterBarProps) {
14
+ return (
15
+ <div className={cn("flex flex-wrap items-center justify-between gap-2", className)}>
16
+ <div className="flex flex-wrap items-center gap-2">{children}</div>
17
+ {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1 @@
1
+ export { FilterBar, type FilterBarProps } from "./filter-bar";
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @elabs-ai/components-data — data-dense UI built on TanStack Table.
3
+ *
4
+ * DataTable owns the table instance and exposes it through a toolbar
5
+ * render-prop so SearchInput, FacetFilter and ColumnPicker can drive it.
6
+ */
7
+ // DataTable + DataTableViewState + DataTableServerArgs (WP-05 #62 new types)
8
+ export * from "./data-table";
9
+ export * from "./search-input";
10
+ export * from "./filter-bar";
11
+ export * from "./facet-filter";
12
+ export * from "./column-picker";
13
+
14
+ // Re-export the most common TanStack types so consumers don't need a direct dep.
15
+ export type { ColumnDef, ColumnPinningState, Table, Row, CellContext } from "@tanstack/react-table";
16
+
17
+ // CSV helpers — pure, dependency-free serializer + browser download trigger.
18
+ export * from "./to-csv";
@@ -0,0 +1 @@
1
+ export { SearchInput, type SearchInputProps } from "./search-input";
@@ -0,0 +1,45 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { SearchInput } from "./search-input";
4
+
5
+ const meta = {
6
+ title: "Data/SearchInput",
7
+ component: SearchInput,
8
+ parameters: {
9
+ layout: "padded",
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "Controlled search field with a leading icon and a clear button. Pair it with " +
14
+ "`FilterBar` and drive a DataTable's global filter from the `toolbar` render-prop. " +
15
+ "The label is visually hidden but real — the placeholder is never the accessible name.",
16
+ },
17
+ },
18
+ },
19
+ tags: ["autodocs"],
20
+ } satisfies Meta<typeof SearchInput>;
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ /** Uncontrolled-looking wrapper so the stories are actually typeable. */
25
+ function Controlled({ initial = "", label }: { initial?: string; label?: string }) {
26
+ const [value, setValue] = useState(initial);
27
+ return <SearchInput value={value} onValueChange={setValue} label={label} />;
28
+ }
29
+
30
+ export const Default: Story = {
31
+ args: { value: "", onValueChange: () => {} },
32
+ render: () => <Controlled />,
33
+ };
34
+
35
+ /** With a value the clear button appears, named "Clear search" for AT. */
36
+ export const WithValue: Story = {
37
+ args: { value: "billing", onValueChange: () => {} },
38
+ render: () => <Controlled initial="billing" />,
39
+ };
40
+
41
+ /** The visually-hidden label is overridable when "Search" is too vague. */
42
+ export const CustomLabel: Story = {
43
+ args: { value: "", onValueChange: () => {} },
44
+ render: () => <Controlled label="Filter deployments" />,
45
+ };