@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elabs-ai/components-data",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"registry": "https://registry.npmjs.org/"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@dnd-kit/core": "6.3.1",
|
|
28
|
+
"@dnd-kit/sortable": "10.0.0",
|
|
29
|
+
"@dnd-kit/utilities": "3.2.2",
|
|
27
30
|
"@tanstack/react-table": "^8.20.6",
|
|
28
31
|
"@tanstack/react-virtual": "^3.14.2",
|
|
29
32
|
"lucide-react": "^0.577.0"
|
|
@@ -31,9 +34,9 @@
|
|
|
31
34
|
"peerDependencies": {
|
|
32
35
|
"react": "^18.2.0 || ^19.0.0",
|
|
33
36
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
34
|
-
"@elabs-ai/components-icons": "4.
|
|
35
|
-
"@elabs-ai/components-
|
|
36
|
-
"@elabs-ai/components-
|
|
37
|
+
"@elabs-ai/components-icons": "4.2.0",
|
|
38
|
+
"@elabs-ai/components-tokens": "4.2.0",
|
|
39
|
+
"@elabs-ai/components-ui": "4.2.0"
|
|
37
40
|
},
|
|
38
41
|
"devDependencies": {
|
|
39
42
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -49,10 +52,10 @@
|
|
|
49
52
|
"typescript": "^5.7.3",
|
|
50
53
|
"vitest": "^3.0.2",
|
|
51
54
|
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
52
|
-
"@elabs-ai/components-icons": "4.
|
|
53
|
-
"@elabs-ai/components-tokens": "4.
|
|
55
|
+
"@elabs-ai/components-icons": "4.2.0",
|
|
56
|
+
"@elabs-ai/components-tokens": "4.2.0",
|
|
54
57
|
"@elabs-ai/components-typescript-config": "0.1.0",
|
|
55
|
-
"@elabs-ai/components-ui": "4.
|
|
58
|
+
"@elabs-ai/components-ui": "4.2.0"
|
|
56
59
|
},
|
|
57
60
|
"scripts": {
|
|
58
61
|
"build": "tsup",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
|
|
2
|
+
/**
|
|
3
|
+
* Contract probe for FilterChip (`packages/data/src/filter-bar/filter-chip.tsx`), derived from its
|
|
4
|
+
* `Default` story (packages/data/src/filter-bar/filter-chip.stories.tsx). See scripts/gen-contract-tests.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
7
|
+
import { render, cleanup } from "@testing-library/react";
|
|
8
|
+
import { createRef } from "react";
|
|
9
|
+
import * as stories from "../filter-bar/filter-chip.stories";
|
|
10
|
+
import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
// Every component here has its own prop/ref/element shape; a generated probe
|
|
15
|
+
// stays generic on purpose (loosely typed, not untyped — see
|
|
16
|
+
// scripts/gen-contract-tests.mjs) rather than re-deriving each one.
|
|
17
|
+
const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
|
|
18
|
+
const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
|
|
19
|
+
const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
|
|
21
|
+
const Component = meta.component as any;
|
|
22
|
+
const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
|
|
23
|
+
const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
|
|
24
|
+
|
|
25
|
+
/** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
|
|
26
|
+
function contractIt(assertion: string, name: string, fn: () => void) {
|
|
27
|
+
const key = `data-filterbar-filterchip--default|jsdom|jsdom|${assertion}`;
|
|
28
|
+
const reason = KNOWN_FAILURES[key];
|
|
29
|
+
if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
|
|
30
|
+
return it(name, fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("FilterChip contract", () => {
|
|
34
|
+
it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
|
|
35
|
+
const ref = createRef<Element>();
|
|
36
|
+
render(<Component {...args} ref={ref} />);
|
|
37
|
+
expect(ref.current).toBeInstanceOf(Element);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
contractIt("className", "merges a caller className onto the root", () => {
|
|
41
|
+
const { container } = render(<Component {...args} className="contract-probe" />);
|
|
42
|
+
expect(container.querySelector(".contract-probe")).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
contractIt("data-slot", 'exposes data-slot="filter-chip" on its root', () => {
|
|
46
|
+
const { container } = render(<Component {...args} />);
|
|
47
|
+
expect(container.querySelector('[data-slot="filter-chip"]')).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -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
|
|
|
@@ -19,19 +20,21 @@ export interface ColumnPickerProps<TData> extends ButtonHTMLAttributes<HTMLButto
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function ColumnPickerInner<TData>(
|
|
22
|
-
{ table, label
|
|
23
|
+
{ table, label, className, ...props }: ColumnPickerProps<TData>,
|
|
23
24
|
ref: Ref<HTMLButtonElement>,
|
|
24
25
|
) {
|
|
26
|
+
const { t } = useLocale();
|
|
27
|
+
const resolvedLabel = label ?? t("data.columnPicker.label");
|
|
25
28
|
const columns = table.getAllColumns().filter((c) => c.getCanHide());
|
|
26
29
|
return (
|
|
27
30
|
<DropdownMenu>
|
|
28
31
|
<DropdownMenuTrigger asChild>
|
|
29
32
|
<Button ref={ref} variant="outline" size="sm" className={cn(className)} {...props}>
|
|
30
|
-
{
|
|
33
|
+
{resolvedLabel}
|
|
31
34
|
</Button>
|
|
32
35
|
</DropdownMenuTrigger>
|
|
33
36
|
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
|
34
|
-
<DropdownMenuLabel>
|
|
37
|
+
<DropdownMenuLabel>{t("data.columnPicker.toggleColumns")}</DropdownMenuLabel>
|
|
35
38
|
<DropdownMenuSeparator />
|
|
36
39
|
{columns.map((column) => (
|
|
37
40
|
<DropdownMenuItem
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
ColumnFiltersState,
|
|
9
9
|
VisibilityState,
|
|
10
10
|
} from "@tanstack/react-table";
|
|
11
|
-
import { DataTable } from "./data-table";
|
|
11
|
+
import { DataTable, createSelectionColumn } from "./data-table";
|
|
12
12
|
import type { DataTableServerArgs, DataTableViewState } from "./data-table";
|
|
13
13
|
import { FilterBar } from "../filter-bar";
|
|
14
14
|
import { SearchInput } from "../search-input";
|
|
@@ -45,7 +45,10 @@ const columns: ColumnDef<Deployment>[] = [
|
|
|
45
45
|
return <Badge variant={statusVariant[s]}>{s}</Badge>;
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
|
-
|
|
48
|
+
// #69: `meta.numeric` is the component seam — the header/body/skeleton
|
|
49
|
+
// renderers apply `tabular-nums` + end-alignment for free, instead of a
|
|
50
|
+
// per-story wrapper span.
|
|
51
|
+
{ accessorKey: "latencyMs", header: "Latency (ms)", meta: { numeric: true } },
|
|
49
52
|
];
|
|
50
53
|
|
|
51
54
|
// `columns` minus the Badge-rendering "Status" cell — for stories below whose
|
|
@@ -66,6 +69,8 @@ const meta = {
|
|
|
66
69
|
docs: {
|
|
67
70
|
description: {
|
|
68
71
|
component:
|
|
72
|
+
"The INTERACTIVE grid; static markup you lay out yourself is `Data/Table` — see " +
|
|
73
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
69
74
|
"The full data grid (TanStack Table): sorting, filtering, pagination, virtualization, row " +
|
|
70
75
|
"selection and column management. For a simple static table with no interaction, the lighter " +
|
|
71
76
|
"Table primitive (see Data/Table, @elabs-ai/components-ui) is enough.",
|
|
@@ -93,6 +98,21 @@ export const Lines: Story = {
|
|
|
93
98
|
render: () => <DataTable columns={columns} data={rows} zebra={false} />,
|
|
94
99
|
};
|
|
95
100
|
|
|
101
|
+
// ─── Column dividers ────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `columnDividers` adds a quiet `--rule` hairline between columns, in the header
|
|
105
|
+
* and the body. Off by default; pinned columns keep their own seam instead.
|
|
106
|
+
*/
|
|
107
|
+
export const ColumnDividers: Story = {
|
|
108
|
+
render: () => <DataTable columns={columns} data={rows} zebra={false} columnDividers />,
|
|
109
|
+
play: async ({ canvasElement }) => {
|
|
110
|
+
const header = canvasElement.querySelector("thead th");
|
|
111
|
+
await expect(header).not.toBeNull();
|
|
112
|
+
await expect(getComputedStyle(header as Element).borderInlineEndWidth).toBe("1px");
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
96
116
|
// ─── Sorted ───────────────────────────────────────────────────────────────────
|
|
97
117
|
|
|
98
118
|
export const Sorted: Story = {
|
|
@@ -186,6 +206,16 @@ export const WithToolbar: Story = {
|
|
|
186
206
|
// matching row should survive. Proves the render-prop toolbar and table share
|
|
187
207
|
// one filter state.
|
|
188
208
|
play: async ({ canvas, userEvent }) => {
|
|
209
|
+
// #69 — this IS the exact "data-datatable--with-toolbar" acceptance
|
|
210
|
+
// surface the issue names: the "Latency (ms)" column's `meta.numeric`
|
|
211
|
+
// must reach the REAL rendered header + cell (computed style, not just a
|
|
212
|
+
// class-name assertion) as `text-align: end` + `tabular-nums`.
|
|
213
|
+
const latencyHeader = canvas.getByRole("columnheader", { name: "Latency (ms)" });
|
|
214
|
+
await expect(getComputedStyle(latencyHeader).textAlign).toBe("end");
|
|
215
|
+
const latencyCell = canvas.getByRole("cell", { name: "82" });
|
|
216
|
+
await expect(getComputedStyle(latencyCell).textAlign).toBe("end");
|
|
217
|
+
await expect(getComputedStyle(latencyCell).fontVariantNumeric).toContain("tabular-nums");
|
|
218
|
+
|
|
189
219
|
await expect(canvas.getByText("api-gateway")).toBeVisible();
|
|
190
220
|
await userEvent.type(canvas.getByPlaceholderText(/Filter services/), "billing");
|
|
191
221
|
await waitFor(() => expect(canvas.queryByText("api-gateway")).toBeNull());
|
|
@@ -565,6 +595,12 @@ export const ClickableRows: Story = {
|
|
|
565
595
|
const rowAction = canvas.getByRole("button", { name: "search" });
|
|
566
596
|
rowAction.focus();
|
|
567
597
|
await expect(rowAction).toHaveFocus();
|
|
598
|
+
// #311: the row paints the deliberate compound focus indicator via
|
|
599
|
+
// `has-[[data-slot=data-table-row-action]:focus-visible]:…` — the proxy
|
|
600
|
+
// button itself must suppress its OWN native ring, or it leaks as a
|
|
601
|
+
// stray dot at the row's edge. Real browser (Playwright), so this
|
|
602
|
+
// actually resolves the Tailwind cascade, unlike the jsdom unit test.
|
|
603
|
+
await expect(getComputedStyle(rowAction).outlineStyle).toBe("none");
|
|
568
604
|
await userEvent.keyboard("{Enter}");
|
|
569
605
|
await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(2);
|
|
570
606
|
await expect(clickableRowsOnRowClick.mock.calls[1]![0].original.service).toBe("search");
|
|
@@ -802,3 +838,461 @@ export const PinnedColumnsClassicLines: Story = {
|
|
|
802
838
|
await expect(getComputedStyle(oddPinned.closest("tr")!).borderBottomWidth).toBe("1px");
|
|
803
839
|
},
|
|
804
840
|
};
|
|
841
|
+
|
|
842
|
+
// ─── WithColumnResizing (#12) ───────────────────────────────────────────────────
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* `enableColumnResizing` adds a drag handle to the end of every resizable header
|
|
846
|
+
* cell — a WAI-ARIA separator-as-slider, operable by pointer/touch (TanStack's own
|
|
847
|
+
* `getResizeHandler()`) or keyboard (ArrowLeft/ArrowRight while the handle is
|
|
848
|
+
* focused). `columnSizing`/`onColumnSizingChange` make it a controlled slice,
|
|
849
|
+
* exactly like sorting or column pinning; omit them to let the table manage its
|
|
850
|
+
* own widths. A resized column that is ALSO pinned keeps its sticky offset in
|
|
851
|
+
* sync for free — pinning already reads `column.getSize()`.
|
|
852
|
+
*/
|
|
853
|
+
export const WithColumnResizing: Story = {
|
|
854
|
+
parameters: {
|
|
855
|
+
docs: {
|
|
856
|
+
description: {
|
|
857
|
+
story:
|
|
858
|
+
"`enableColumnResizing` lets a reader drag — or, with the handle focused, " +
|
|
859
|
+
"arrow-key — a column's edge to change its width.",
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
render: () => <DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />,
|
|
864
|
+
play: async ({ canvas, userEvent }) => {
|
|
865
|
+
const serviceHeader = canvas.getAllByRole("columnheader")[0]!;
|
|
866
|
+
await expect(serviceHeader.style.width).toBe("150px");
|
|
867
|
+
|
|
868
|
+
const handle = canvas.getByRole("separator", { name: /Resize column, Service/i });
|
|
869
|
+
|
|
870
|
+
// #51 — the focus ring moved from the hit box itself onto the `after:`
|
|
871
|
+
// drawn-seam pseudo-element (`focus-visible:after:ring-2`), so the now-24px
|
|
872
|
+
// hit box doesn't draw a 24px focus rectangle over an 8px sliver's worth of
|
|
873
|
+
// approved contrast. Measure the REAL rendered ring via computed style on
|
|
874
|
+
// the pseudo-element — not a class-name string comparison — so a later
|
|
875
|
+
// refactor can't silently drop a visible focus indicator (WCAG 2.4.7)
|
|
876
|
+
// without failing this test.
|
|
877
|
+
await expect(getComputedStyle(handle, "::after").boxShadow).toBe("none");
|
|
878
|
+
handle.focus();
|
|
879
|
+
await expect(getComputedStyle(handle, "::after").boxShadow).not.toBe("none");
|
|
880
|
+
|
|
881
|
+
await expect(handle).toHaveAttribute("aria-valuenow", "150");
|
|
882
|
+
|
|
883
|
+
await userEvent.keyboard("{ArrowRight}");
|
|
884
|
+
await expect(handle).toHaveAttribute("aria-valuenow", "160");
|
|
885
|
+
await expect(serviceHeader.style.width).toBe("160px");
|
|
886
|
+
|
|
887
|
+
await userEvent.keyboard("{ArrowLeft}");
|
|
888
|
+
await expect(handle).toHaveAttribute("aria-valuenow", "150");
|
|
889
|
+
await expect(serviceHeader.style.width).toBe("150px");
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* #51 — the resize handle's hit box used to be `w-2` (8px), which is on the
|
|
895
|
+
* Tailwind SPACING scale (`calc(var(--spacing) * 2)`), so it shrank further
|
|
896
|
+
* under `data-density="compact"` (`--spacing` itself is what the density dial
|
|
897
|
+
* rescales — a jsdom class assertion can't see this, only real layout can). The
|
|
898
|
+
* fix reads a literal `w-[min(24px,50%)]`, which this play function measures
|
|
899
|
+
* with `getBoundingClientRect()` UNDER compact density specifically, since
|
|
900
|
+
* that is where the old value was worst (~7.1px) and where a regression would
|
|
901
|
+
* hide from a comfortable-only check.
|
|
902
|
+
*/
|
|
903
|
+
export const WithColumnResizingCompactDensity: Story = {
|
|
904
|
+
parameters: {
|
|
905
|
+
docs: {
|
|
906
|
+
description: {
|
|
907
|
+
story:
|
|
908
|
+
"The resize handle's interactive hit box stays ~24px wide even under " +
|
|
909
|
+
'`data-density="compact"`, which shrinks every Tailwind spacing-scale ' +
|
|
910
|
+
"utility (the old `w-2` hit box shrank right along with it).",
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
},
|
|
914
|
+
render: () => (
|
|
915
|
+
<div data-density="compact">
|
|
916
|
+
<DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />
|
|
917
|
+
</div>
|
|
918
|
+
),
|
|
919
|
+
play: async ({ canvas }) => {
|
|
920
|
+
const handle = canvas.getByRole("separator", { name: /Resize column, Service/i });
|
|
921
|
+
const rect = handle.getBoundingClientRect();
|
|
922
|
+
// A real layout pass must have happened before this means anything.
|
|
923
|
+
await expect(rect.width).toBeGreaterThan(0);
|
|
924
|
+
// `min(24px, 50%)` on a 150px column resolves to a literal 24px — assert a
|
|
925
|
+
// tight tolerance so a regression back toward the old ~7px is caught, but
|
|
926
|
+
// allow for sub-pixel rounding.
|
|
927
|
+
await expect(rect.width).toBeGreaterThan(20);
|
|
928
|
+
await expect(rect.width).toBeLessThanOrEqual(24.5);
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* #82 gap 1 — the double-click-reset regression locks in `data-table.test.tsx`
|
|
934
|
+
* use jsdom's `fireEvent.doubleClick`, which dispatches ONLY a synthetic
|
|
935
|
+
* `dblclick` event — never the two real `mousedown`/`mouseup` pairs a browser
|
|
936
|
+
* fires for an actual double click, which `header.getResizeHandler()` (wired
|
|
937
|
+
* to `onMouseDown` on the SAME handle) also listens for. `userEvent.dblClick`
|
|
938
|
+
* DOES synthesize the full mousedown/mouseup ×2 + dblclick sequence, and this
|
|
939
|
+
* play function runs in a real browser (Storybook test runner), so it
|
|
940
|
+
* exercises the actual event path a user's double click takes — and asserts
|
|
941
|
+
* the column lands exactly at its declared size, with no partial resize left
|
|
942
|
+
* behind by whatever `getResizeHandler()`'s pointer-drag state machine did
|
|
943
|
+
* with those two extra mousedown/mouseup pairs.
|
|
944
|
+
*/
|
|
945
|
+
export const WithColumnResizingRealDoubleClick: Story = {
|
|
946
|
+
parameters: {
|
|
947
|
+
docs: {
|
|
948
|
+
description: {
|
|
949
|
+
story:
|
|
950
|
+
"A REAL double-click (mousedown/mouseup ×2 + dblclick, not jsdom's " +
|
|
951
|
+
"synthetic dblclick-only event) resets a resized column to its " +
|
|
952
|
+
"declared size, with no partial resize left behind by the drag path.",
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
render: () => <DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />,
|
|
957
|
+
play: async ({ canvas, userEvent }) => {
|
|
958
|
+
const serviceHeader = canvas.getAllByRole("columnheader")[0]!;
|
|
959
|
+
const handle = canvas.getByRole("separator", { name: /Resize column, Service/i });
|
|
960
|
+
await expect(serviceHeader.style.width).toBe("150px");
|
|
961
|
+
|
|
962
|
+
// Move away from the declared size first (keyboard path — already proven
|
|
963
|
+
// real-pointer-equivalent by the plain WithColumnResizing story above),
|
|
964
|
+
// so the double click below has something to reset FROM.
|
|
965
|
+
handle.focus();
|
|
966
|
+
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
|
|
967
|
+
await expect(serviceHeader.style.width).toBe("170px");
|
|
968
|
+
|
|
969
|
+
// The real double click — the actual mousedown/mouseup ×2 + dblclick
|
|
970
|
+
// sequence, not jsdom's dblclick-only synthetic event.
|
|
971
|
+
await userEvent.dblClick(handle);
|
|
972
|
+
await expect(handle).toHaveAttribute("aria-valuenow", "150");
|
|
973
|
+
await expect(serviceHeader.style.width).toBe("150px");
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* #82 gap 2 — the resize handle's `min(24px, 50%)` hit box sits over the
|
|
979
|
+
* trailing edge of the SAME header cell a sortable column's own toggle
|
|
980
|
+
* button occupies. jsdom's `fireEvent.click` dispatches straight to a target
|
|
981
|
+
* node with no real hit-testing, so the regression lock in
|
|
982
|
+
* `data-table.test.tsx` can only prove the button RESPONDS to a click
|
|
983
|
+
* addressed to it — not that a real screen click at the button's own
|
|
984
|
+
* on-screen coordinates actually resolves to the button rather than the
|
|
985
|
+
* handle. `document.elementFromPoint` is the browser's real hit-test; this
|
|
986
|
+
* play function samples it right at the sort button's own trailing edge (the
|
|
987
|
+
* side nearest the handle) and asserts it resolves inside the button, then
|
|
988
|
+
* performs the click there and asserts the sort actually toggled.
|
|
989
|
+
*
|
|
990
|
+
* Sampled on TWO columns, not one, because the collision is alignment-
|
|
991
|
+
* dependent: a `start`-aligned header's button sits at the LEADING edge,
|
|
992
|
+
* nowhere near the handle, so it can't exercise the collision at all — round
|
|
993
|
+
* 1 review (independent real-Chromium validation) caught exactly this: the
|
|
994
|
+
* original version of this story sampled only "Service" (start-aligned,
|
|
995
|
+
* button ends 293px clear of the handle) and passed trivially, while
|
|
996
|
+
* "Latency (ms)" — numeric, so `meta.numeric` end-aligns it (#69) — pushes
|
|
997
|
+
* the SAME button's trailing edge 12px UNDER the handle's 24px hit box. The
|
|
998
|
+
* "Service" case is kept because a real regression here (e.g. the handle
|
|
999
|
+
* growing) should still be caught on the unproblematic column too.
|
|
1000
|
+
*/
|
|
1001
|
+
export const WithColumnResizingSortToggleHitTest: Story = {
|
|
1002
|
+
parameters: {
|
|
1003
|
+
docs: {
|
|
1004
|
+
description: {
|
|
1005
|
+
story:
|
|
1006
|
+
"The header's sort-toggle button stays hit-testable at its own " +
|
|
1007
|
+
"coordinates when the column is ALSO resizable, i.e. when the " +
|
|
1008
|
+
"24px resize handle is present on the same header cell — for both " +
|
|
1009
|
+
"a start-aligned header and an end-aligned numeric one.",
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
render: () => <DataTable columns={columnsNoBadge} data={rows} enableColumnResizing />,
|
|
1014
|
+
play: async ({ canvas, userEvent }) => {
|
|
1015
|
+
async function assertSortButtonHitTestable(
|
|
1016
|
+
accessibleName: string,
|
|
1017
|
+
columnHeaderIndex: number,
|
|
1018
|
+
// TanStack defaults a NUMBER-typed column to descending-first (largest
|
|
1019
|
+
// first reads more useful than smallest-first for e.g. latency) — this
|
|
1020
|
+
// is TanStack's own default heuristic, not something this story
|
|
1021
|
+
// asserts against; the string-typed "Service" column defaults
|
|
1022
|
+
// ascending-first as usual.
|
|
1023
|
+
firstClickSortDirection: "ascending" | "descending",
|
|
1024
|
+
) {
|
|
1025
|
+
const sortButton = canvas.getByRole("button", { name: accessibleName });
|
|
1026
|
+
const rect = sortButton.getBoundingClientRect();
|
|
1027
|
+
// A real layout pass must have happened before this means anything.
|
|
1028
|
+
await expect(rect.width).toBeGreaterThan(0);
|
|
1029
|
+
|
|
1030
|
+
// The browser's REAL hit-test, sampled 2px inside the button's own
|
|
1031
|
+
// trailing edge — the side nearest the resize handle — so the sample
|
|
1032
|
+
// point can't land outside the button from sub-pixel rounding.
|
|
1033
|
+
const x = rect.right - 2;
|
|
1034
|
+
const y = rect.top + rect.height / 2;
|
|
1035
|
+
const hit = document.elementFromPoint(x, y);
|
|
1036
|
+
await expect(hit).not.toBeNull();
|
|
1037
|
+
await expect(sortButton.contains(hit)).toBe(true);
|
|
1038
|
+
|
|
1039
|
+
const columnHeader = canvas.getAllByRole("columnheader")[columnHeaderIndex]!;
|
|
1040
|
+
await expect(columnHeader).toHaveAttribute("aria-sort", "none");
|
|
1041
|
+
await userEvent.click(hit!);
|
|
1042
|
+
await expect(columnHeader).toHaveAttribute("aria-sort", firstClickSortDirection);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Start-aligned — the button sits far from the handle; kept as the
|
|
1046
|
+
// "nothing regressed on the easy case" control.
|
|
1047
|
+
await assertSortButtonHitTestable("Sort by Service, not sorted", 0, "ascending");
|
|
1048
|
+
// End-aligned numeric — the actual collision surface (#69's `meta.numeric`
|
|
1049
|
+
// pushes this button's trailing edge under the resize handle).
|
|
1050
|
+
await assertSortButtonHitTestable("Sort by Latency (ms), not sorted", 2, "descending");
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
// ─── Row selection (#11) ───────────────────────────────────────────────────────
|
|
1055
|
+
|
|
1056
|
+
const selectableColumns: ColumnDef<Deployment>[] = [
|
|
1057
|
+
createSelectionColumn<Deployment>(),
|
|
1058
|
+
...columnsNoBadge,
|
|
1059
|
+
];
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* `createSelectionColumn()` drops a ready-made checkbox column onto any table:
|
|
1063
|
+
* a header select-all (real `indeterminate` for a partial page selection) plus
|
|
1064
|
+
* a per-row checkbox. Uncontrolled here — the table owns `rowSelection`
|
|
1065
|
+
* internally; pass `rowSelection`/`onRowSelectionChange` to drive it from the
|
|
1066
|
+
* app instead.
|
|
1067
|
+
*/
|
|
1068
|
+
export const RowSelectionUnselected: Story = {
|
|
1069
|
+
render: () => <DataTable columns={selectableColumns} data={rows} />,
|
|
1070
|
+
play: async ({ canvas }) => {
|
|
1071
|
+
const selectAll = canvas.getAllByRole("checkbox")[0]!;
|
|
1072
|
+
await expect(selectAll).toHaveAttribute("aria-checked", "false");
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
/** A controlled, partial selection: the header checkbox reads `indeterminate`. */
|
|
1077
|
+
export const RowSelectionPartial: Story = {
|
|
1078
|
+
render: () => (
|
|
1079
|
+
<DataTable
|
|
1080
|
+
columns={selectableColumns}
|
|
1081
|
+
data={rows}
|
|
1082
|
+
rowSelection={{ "0": true, "2": true }}
|
|
1083
|
+
onRowSelectionChange={fn()}
|
|
1084
|
+
/>
|
|
1085
|
+
),
|
|
1086
|
+
play: async ({ canvas }) => {
|
|
1087
|
+
const selectAll = canvas.getAllByRole("checkbox")[0]!;
|
|
1088
|
+
await expect(selectAll).toHaveAttribute("aria-checked", "mixed");
|
|
1089
|
+
},
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
/** A controlled, full-page selection: the header checkbox reads `checked`. */
|
|
1093
|
+
export const RowSelectionAllSelected: Story = {
|
|
1094
|
+
render: () => (
|
|
1095
|
+
<DataTable
|
|
1096
|
+
columns={selectableColumns}
|
|
1097
|
+
data={rows}
|
|
1098
|
+
rowSelection={Object.fromEntries(rows.map((_, i) => [String(i), true]))}
|
|
1099
|
+
onRowSelectionChange={fn()}
|
|
1100
|
+
/>
|
|
1101
|
+
),
|
|
1102
|
+
play: async ({ canvas }) => {
|
|
1103
|
+
const selectAll = canvas.getAllByRole("checkbox")[0]!;
|
|
1104
|
+
await expect(selectAll).toHaveAttribute("aria-checked", "true");
|
|
1105
|
+
},
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* A bulk-action toolbar is not a separate prop — `toolbar={(table) => …}`
|
|
1110
|
+
* already hands the live table instance to the render-prop, so a consumer
|
|
1111
|
+
* builds "N selected · Clear" from `table.getSelectedRowModel()` the same way
|
|
1112
|
+
* `WithToolbar` builds a filter bar from the same instance.
|
|
1113
|
+
*/
|
|
1114
|
+
export const RowSelectionWithToolbar: Story = {
|
|
1115
|
+
render: () => (
|
|
1116
|
+
<DataTable
|
|
1117
|
+
columns={selectableColumns}
|
|
1118
|
+
data={rows}
|
|
1119
|
+
toolbar={(table) => {
|
|
1120
|
+
const selectedCount = table.getSelectedRowModel().rows.length;
|
|
1121
|
+
if (selectedCount === 0) return null;
|
|
1122
|
+
return (
|
|
1123
|
+
<FilterBar
|
|
1124
|
+
actions={
|
|
1125
|
+
<Button
|
|
1126
|
+
type="button"
|
|
1127
|
+
size="sm"
|
|
1128
|
+
variant="outline"
|
|
1129
|
+
onClick={() => table.resetRowSelection()}
|
|
1130
|
+
>
|
|
1131
|
+
Clear selection
|
|
1132
|
+
</Button>
|
|
1133
|
+
}
|
|
1134
|
+
>
|
|
1135
|
+
<span className="text-body">{selectedCount} selected</span>
|
|
1136
|
+
</FilterBar>
|
|
1137
|
+
);
|
|
1138
|
+
}}
|
|
1139
|
+
/>
|
|
1140
|
+
),
|
|
1141
|
+
play: async ({ canvas, userEvent }) => {
|
|
1142
|
+
await expect(canvas.queryByText(/selected$/)).toBeNull();
|
|
1143
|
+
const rowCheckboxes = canvas.getAllByRole("checkbox").slice(1);
|
|
1144
|
+
await userEvent.click(rowCheckboxes[0]!);
|
|
1145
|
+
await userEvent.click(rowCheckboxes[1]!);
|
|
1146
|
+
await expect(canvas.getByText("2 selected")).toBeVisible();
|
|
1147
|
+
await userEvent.click(canvas.getByText("Clear selection"));
|
|
1148
|
+
await expect(canvas.queryByText(/selected$/)).toBeNull();
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Dark-theme pass for the same interaction + a11y coverage (#11 fix round —
|
|
1154
|
+
* the light-only Storybook run isn't sufficient for the "both shipping
|
|
1155
|
+
* themes" quality gate). Reuses `RowSelectionWithToolbar`'s render/play under
|
|
1156
|
+
* `globals: { theme: "dark" }`, same pattern as `dialog.stories.tsx`'s
|
|
1157
|
+
* `FocusRingClearanceDark`.
|
|
1158
|
+
*/
|
|
1159
|
+
export const RowSelectionWithToolbarDark: Story = {
|
|
1160
|
+
name: "Row selection — with toolbar (dark)",
|
|
1161
|
+
globals: { theme: "dark" },
|
|
1162
|
+
render: RowSelectionWithToolbar.render,
|
|
1163
|
+
play: async (context) => {
|
|
1164
|
+
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("dark"));
|
|
1165
|
+
await RowSelectionWithToolbar.play!(context);
|
|
1166
|
+
},
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
// ─── RowReorder (#13) ───────────────────────────────────────────────────────
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Opt-in row drag-reorder. Fully controlled, like every other DataTable slice:
|
|
1173
|
+
* `onRowReorder` reports the move as `(from, to, row)` and this story
|
|
1174
|
+
* re-orders its own `data` in response — the component never mutates `data`
|
|
1175
|
+
* itself.
|
|
1176
|
+
*
|
|
1177
|
+
* Try both ways to move a row: drag the grip handle with a mouse, or Tab to
|
|
1178
|
+
* it and use the keyboard — Space/Enter picks a row up, Arrow Up/Down moves
|
|
1179
|
+
* it, Space/Enter drops it, Escape cancels. Every position change is
|
|
1180
|
+
* announced through a live region (WCAG 4.1.3).
|
|
1181
|
+
*/
|
|
1182
|
+
export const RowReorder: Story = {
|
|
1183
|
+
render: () => {
|
|
1184
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
1185
|
+
const [items, setItems] = useState(rows);
|
|
1186
|
+
return (
|
|
1187
|
+
<DataTable
|
|
1188
|
+
columns={columnsNoBadge}
|
|
1189
|
+
data={items}
|
|
1190
|
+
enableRowReorder
|
|
1191
|
+
onRowReorder={(from, to) => {
|
|
1192
|
+
setItems((current) => {
|
|
1193
|
+
const next = current.slice();
|
|
1194
|
+
const [moved] = next.splice(from, 1);
|
|
1195
|
+
next.splice(to, 0, moved!);
|
|
1196
|
+
return next;
|
|
1197
|
+
});
|
|
1198
|
+
}}
|
|
1199
|
+
/>
|
|
1200
|
+
);
|
|
1201
|
+
},
|
|
1202
|
+
play: async ({ canvas, userEvent }) => {
|
|
1203
|
+
// Keyboard flow — the mandatory path (issue #13 carries the
|
|
1204
|
+
// `accessibility` label): pick up the first row ("api-gateway"), move it
|
|
1205
|
+
// down one position, and drop.
|
|
1206
|
+
const handle = canvas.getByRole("button", { name: "Reorder api-gateway" });
|
|
1207
|
+
handle.focus();
|
|
1208
|
+
await expect(handle).toHaveFocus();
|
|
1209
|
+
|
|
1210
|
+
await userEvent.keyboard(" ");
|
|
1211
|
+
await expect(handle).toHaveAttribute("aria-pressed", "true");
|
|
1212
|
+
|
|
1213
|
+
// `KeyboardSensor.attach()` (`@dnd-kit/core`) defers attaching the
|
|
1214
|
+
// listener for the subsequent move/drop keys by one real macrotask
|
|
1215
|
+
// (`setTimeout(fn, 0)`) — the same tick the unit tests in
|
|
1216
|
+
// `data-table.test.tsx` wait out.
|
|
1217
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1218
|
+
|
|
1219
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
1220
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1221
|
+
|
|
1222
|
+
await userEvent.keyboard(" ");
|
|
1223
|
+
|
|
1224
|
+
await waitFor(() => {
|
|
1225
|
+
const gripButtons = canvas.getAllByRole("button", { name: /^Reorder /i });
|
|
1226
|
+
expect(gripButtons[0]).toHaveAccessibleName("Reorder billing");
|
|
1227
|
+
expect(gripButtons[1]).toHaveAccessibleName("Reorder api-gateway");
|
|
1228
|
+
});
|
|
1229
|
+
},
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* `rowReorderHandle="row"` makes the whole row the drag activator — no extra
|
|
1234
|
+
* grip column. Reach for this only when the row has no other primary
|
|
1235
|
+
* interaction (no `onRowClick`), since the row itself and a click target
|
|
1236
|
+
* would otherwise compete for the same surface.
|
|
1237
|
+
*/
|
|
1238
|
+
export const RowReorderWholeRow: Story = {
|
|
1239
|
+
render: () => {
|
|
1240
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
1241
|
+
const [items, setItems] = useState(rows);
|
|
1242
|
+
return (
|
|
1243
|
+
<DataTable
|
|
1244
|
+
columns={columnsNoBadge}
|
|
1245
|
+
data={items}
|
|
1246
|
+
enableRowReorder
|
|
1247
|
+
rowReorderHandle="row"
|
|
1248
|
+
onRowReorder={(from, to) => {
|
|
1249
|
+
setItems((current) => {
|
|
1250
|
+
const next = current.slice();
|
|
1251
|
+
const [moved] = next.splice(from, 1);
|
|
1252
|
+
next.splice(to, 0, moved!);
|
|
1253
|
+
return next;
|
|
1254
|
+
});
|
|
1255
|
+
}}
|
|
1256
|
+
/>
|
|
1257
|
+
);
|
|
1258
|
+
},
|
|
1259
|
+
play: async ({ canvas, userEvent }) => {
|
|
1260
|
+
// Pointer drag — the other half of acceptance criterion "Pointer drag
|
|
1261
|
+
// works (via @dnd-kit/sortable sensor)". This can only be verified in a
|
|
1262
|
+
// REAL browser: `data-table.test.tsx` documents that jsdom has no
|
|
1263
|
+
// `PointerEvent` constructor at all, so `PointerSensor`'s own activator
|
|
1264
|
+
// gate rejects every synthetic pointerdown there regardless of test
|
|
1265
|
+
// code. Storybook's interaction-test runner drives real Chromium, which
|
|
1266
|
+
// has a genuine `PointerEvent`.
|
|
1267
|
+
// Drags the whole first row ("api-gateway") past the second row
|
|
1268
|
+
// ("billing") and drops.
|
|
1269
|
+
const firstRow = canvas.getByText("api-gateway").closest("tr") as HTMLElement;
|
|
1270
|
+
const secondRow = canvas.getByText("billing").closest("tr") as HTMLElement;
|
|
1271
|
+
const from = firstRow.getBoundingClientRect();
|
|
1272
|
+
const to = secondRow.getBoundingClientRect();
|
|
1273
|
+
const startX = from.left + 20;
|
|
1274
|
+
const startY = from.top + from.height / 2;
|
|
1275
|
+
|
|
1276
|
+
// `reorderSensors`'s `PointerSensor` uses `activationConstraint: { distance: 4 }`
|
|
1277
|
+
// (data-table.tsx): dnd-kit's own `handleMove` treats the FIRST move past that
|
|
1278
|
+
// threshold as arming the drag only — it calls `handleStart()` (recording the
|
|
1279
|
+
// *initial* pointer-down position) and returns WITHOUT ever calling `onMove`, so
|
|
1280
|
+
// that event never updates the tracked drag position. A single big pointer jump
|
|
1281
|
+
// straight from the row to the drop target therefore "activates" the drag but
|
|
1282
|
+
// reports no movement at all, and the row lands back where it started. A SECOND
|
|
1283
|
+
// move — after activation — is required to actually report the target position,
|
|
1284
|
+
// so the sequence below moves twice: once just past the 4px threshold, then once
|
|
1285
|
+
// more to the real drop point.
|
|
1286
|
+
await userEvent.pointer([
|
|
1287
|
+
{ keys: "[MouseLeft>]", target: firstRow, coords: { clientX: startX, clientY: startY } },
|
|
1288
|
+
{ coords: { clientX: startX, clientY: startY + 10 } },
|
|
1289
|
+
{ coords: { clientX: to.left + 20, clientY: to.bottom - 4 } },
|
|
1290
|
+
{ keys: "[/MouseLeft]" },
|
|
1291
|
+
]);
|
|
1292
|
+
|
|
1293
|
+
await waitFor(() => {
|
|
1294
|
+
const cells = canvas.getAllByRole("cell");
|
|
1295
|
+
expect(cells[0]).toHaveTextContent("billing");
|
|
1296
|
+
});
|
|
1297
|
+
},
|
|
1298
|
+
};
|