@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +933 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/column-picker/column-picker.stories.tsx +65 -0
- package/src/column-picker/column-picker.test.tsx +134 -0
- package/src/column-picker/column-picker.tsx +75 -0
- package/src/column-picker/index.ts +1 -0
- package/src/data-table/data-table.stories.tsx +804 -0
- package/src/data-table/data-table.test.tsx +1513 -0
- package/src/data-table/data-table.tsx +1375 -0
- package/src/data-table/index.ts +7 -0
- package/src/facet-filter/facet-filter.stories.tsx +121 -0
- package/src/facet-filter/facet-filter.test.tsx +175 -0
- package/src/facet-filter/facet-filter.tsx +104 -0
- package/src/facet-filter/index.ts +1 -0
- package/src/filter-bar/filter-bar.stories.tsx +58 -0
- package/src/filter-bar/filter-bar.test.tsx +60 -0
- package/src/filter-bar/filter-bar.tsx +20 -0
- package/src/filter-bar/index.ts +1 -0
- package/src/index.ts +18 -0
- package/src/search-input/index.ts +1 -0
- package/src/search-input/search-input.stories.tsx +45 -0
- package/src/search-input/search-input.test.tsx +99 -0
- package/src/search-input/search-input.tsx +81 -0
- package/src/templates-data-app.stories.tsx +160 -0
- package/src/to-csv.test.ts +147 -0
- package/src/to-csv.ts +102 -0
|
@@ -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
|
+
};
|