@elabs-ai/components-data 4.1.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/dist/index.d.ts +7 -2
- package/dist/index.js +71 -53
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__contract__/filter-chip.contract.test.tsx +49 -0
- package/src/column-picker/column-picker.tsx +3 -2
- package/src/data-table/data-table.stories.tsx +15 -0
- package/src/data-table/data-table.test.tsx +61 -11
- package/src/data-table/data-table.tsx +54 -15
- package/src/facet-filter/facet-filter.stories.tsx +4 -1
- package/src/facet-filter/facet-filter.test.tsx +3 -3
- package/src/search-input/search-input.test.tsx +35 -0
- package/src/search-input/search-input.tsx +44 -19
- 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",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"react": "^18.2.0 || ^19.0.0",
|
|
36
36
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
37
|
-
"@elabs-ai/components-icons": "4.
|
|
38
|
-
"@elabs-ai/components-
|
|
39
|
-
"@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"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"tsup": "^8.3.5",
|
|
52
52
|
"typescript": "^5.7.3",
|
|
53
53
|
"vitest": "^3.0.2",
|
|
54
|
-
"@elabs-ai/components-icons": "4.1.0",
|
|
55
|
-
"@elabs-ai/components-tokens": "4.1.0",
|
|
56
54
|
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
55
|
+
"@elabs-ai/components-icons": "4.2.0",
|
|
56
|
+
"@elabs-ai/components-tokens": "4.2.0",
|
|
57
57
|
"@elabs-ai/components-typescript-config": "0.1.0",
|
|
58
|
-
"@elabs-ai/components-ui": "4.
|
|
58
|
+
"@elabs-ai/components-ui": "4.2.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
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
|
+
});
|
|
@@ -20,16 +20,17 @@ export interface ColumnPickerProps<TData> extends ButtonHTMLAttributes<HTMLButto
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function ColumnPickerInner<TData>(
|
|
23
|
-
{ table, label
|
|
23
|
+
{ table, label, className, ...props }: ColumnPickerProps<TData>,
|
|
24
24
|
ref: Ref<HTMLButtonElement>,
|
|
25
25
|
) {
|
|
26
26
|
const { t } = useLocale();
|
|
27
|
+
const resolvedLabel = label ?? t("data.columnPicker.label");
|
|
27
28
|
const columns = table.getAllColumns().filter((c) => c.getCanHide());
|
|
28
29
|
return (
|
|
29
30
|
<DropdownMenu>
|
|
30
31
|
<DropdownMenuTrigger asChild>
|
|
31
32
|
<Button ref={ref} variant="outline" size="sm" className={cn(className)} {...props}>
|
|
32
|
-
{
|
|
33
|
+
{resolvedLabel}
|
|
33
34
|
</Button>
|
|
34
35
|
</DropdownMenuTrigger>
|
|
35
36
|
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
|
@@ -98,6 +98,21 @@ export const Lines: Story = {
|
|
|
98
98
|
render: () => <DataTable columns={columns} data={rows} zebra={false} />,
|
|
99
99
|
};
|
|
100
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
|
+
|
|
101
116
|
// ─── Sorted ───────────────────────────────────────────────────────────────────
|
|
102
117
|
|
|
103
118
|
export const Sorted: Story = {
|
|
@@ -592,11 +592,35 @@ describe("DataTable — zebra striping (default) vs lines", () => {
|
|
|
592
592
|
const rows = container.querySelectorAll("tbody tr");
|
|
593
593
|
expect(rows.length).toBe(3);
|
|
594
594
|
// 2nd row (index 1) is striped; 1st/3rd are not — the stripe is the cue.
|
|
595
|
-
expect(rows[0]?.className).not.toContain("bg-
|
|
596
|
-
expect(rows[1]?.className).toContain("bg-
|
|
597
|
-
expect(rows[2]?.className).not.toContain("bg-
|
|
598
|
-
// No row carries a divider (a border on a striped region would be
|
|
599
|
-
|
|
595
|
+
expect(rows[0]?.className).not.toContain("bg-table-stripe");
|
|
596
|
+
expect(rows[1]?.className).toContain("bg-table-stripe");
|
|
597
|
+
expect(rows[2]?.className).not.toContain("bg-table-stripe");
|
|
598
|
+
// No row carries a fixed divider (a border on a striped region would be
|
|
599
|
+
// redundant). The only row rule is the theme-gated width, `0px` by default,
|
|
600
|
+
// for a theme that turns the stripe off.
|
|
601
|
+
rows.forEach((r) => {
|
|
602
|
+
const classes = r.className.split(/\s+/);
|
|
603
|
+
expect(classes).not.toContain("border-b");
|
|
604
|
+
expect(classes).toContain("border-b-(length:--table-row-rule-width)");
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("draws column dividers only when asked", () => {
|
|
609
|
+
const plain = render(<DataTable columns={columns} data={data} />);
|
|
610
|
+
plain.container
|
|
611
|
+
.querySelectorAll("th, td")
|
|
612
|
+
.forEach((c) => expect(c.className.split(/\s+/)).not.toContain("border-e"));
|
|
613
|
+
plain.unmount();
|
|
614
|
+
|
|
615
|
+
const { container } = render(<DataTable columns={columns} data={data} columnDividers />);
|
|
616
|
+
const cells = container.querySelectorAll("thead th, tbody td");
|
|
617
|
+
expect(cells.length).toBeGreaterThan(0);
|
|
618
|
+
cells.forEach((c) => {
|
|
619
|
+
const classes = c.className.split(/\s+/);
|
|
620
|
+
expect(classes).toContain("border-e");
|
|
621
|
+
expect(classes).toContain("border-rule");
|
|
622
|
+
expect(classes).toContain("last:border-e-0");
|
|
623
|
+
});
|
|
600
624
|
});
|
|
601
625
|
|
|
602
626
|
it("draws border-strong dividers and no stripes when zebra is disabled", () => {
|
|
@@ -605,7 +629,7 @@ describe("DataTable — zebra striping (default) vs lines", () => {
|
|
|
605
629
|
rows.forEach((r) => {
|
|
606
630
|
expect(r.className).toContain("border-b");
|
|
607
631
|
expect(r.className).toContain("border-border-strong");
|
|
608
|
-
expect(r.className).not.toContain("bg-
|
|
632
|
+
expect(r.className).not.toContain("bg-table-stripe");
|
|
609
633
|
});
|
|
610
634
|
// Last row drops its divider so it doesn't double with the container border.
|
|
611
635
|
expect(rows[rows.length - 1]?.className).toContain("last:border-b-0");
|
|
@@ -866,6 +890,32 @@ describe("DataTable — #330 the scroll tab stop exists only while the region ov
|
|
|
866
890
|
simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 900, scrollLeft: 0 });
|
|
867
891
|
expect(scrollRegion).not.toHaveAttribute("tabindex");
|
|
868
892
|
});
|
|
893
|
+
|
|
894
|
+
it('gains role="group" once it overflows — a named, non-landmark stop', () => {
|
|
895
|
+
const { container } = render(<DataTable columns={columns} data={data} />);
|
|
896
|
+
const scrollRegion = scrollRegionOf(container);
|
|
897
|
+
// `aria-label` on a plain `<div>` (role `generic`) is not guaranteed to
|
|
898
|
+
// produce an accessible name at all — the stop needs a naming-capable
|
|
899
|
+
// role, and `group` (not the `region` landmark, which collides under axe
|
|
900
|
+
// `landmark-unique` across tables) is it.
|
|
901
|
+
expect(scrollRegion).not.toHaveAttribute("role");
|
|
902
|
+
simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 300, scrollLeft: 0 });
|
|
903
|
+
expect(scrollRegion).toHaveAttribute("role", "group");
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
describe("DataTable — virtualized scroll region has a naming-capable role", () => {
|
|
908
|
+
it('the always-focusable virtualized scroll container carries role="group"', () => {
|
|
909
|
+
const { container } = render(
|
|
910
|
+
<DataTable columns={columns} data={data} enableRowVirtualization />,
|
|
911
|
+
);
|
|
912
|
+
const scroll = container.querySelector(".overflow-auto");
|
|
913
|
+
expect(scroll).toHaveAttribute("tabindex", "0");
|
|
914
|
+
expect(scroll).toHaveAttribute("aria-label");
|
|
915
|
+
// Same reasoning as the non-virtualized branch above: `aria-label` alone
|
|
916
|
+
// on a role-less `<div>` is not guaranteed to compute an accessible name.
|
|
917
|
+
expect(scroll).toHaveAttribute("role", "group");
|
|
918
|
+
});
|
|
869
919
|
});
|
|
870
920
|
|
|
871
921
|
describe("DataTable — #330 horizontal-scroll edge-fade affordance", () => {
|
|
@@ -1177,7 +1227,7 @@ describe("DataTable — #337 onRowClick + rowClassName", () => {
|
|
|
1177
1227
|
const betaRow = screen.getByText("Beta").closest("tr")!;
|
|
1178
1228
|
expect(betaRow).toHaveClass("is-highlighted");
|
|
1179
1229
|
// Beta is row index 1 — the zebra stripe class must still be present.
|
|
1180
|
-
expect(betaRow.className).toContain("bg-
|
|
1230
|
+
expect(betaRow.className).toContain("bg-table-stripe");
|
|
1181
1231
|
});
|
|
1182
1232
|
|
|
1183
1233
|
it("gives a clickable row a pointer cursor and a focus ring driven by its activation button", () => {
|
|
@@ -1393,10 +1443,10 @@ describe("DataTable — #333 pinned cells compose with the row wash, not overpai
|
|
|
1393
1443
|
expect(odd!.className).toContain("bg-card");
|
|
1394
1444
|
// Only the striped row re-applies the wash, on the decorative ::before layer
|
|
1395
1445
|
// — this is the bug #333 reports: a single opaque fill erased the stripe.
|
|
1396
|
-
expect(odd!.className).toContain("before:bg-
|
|
1397
|
-
expect(even!.className).not.toContain("before:bg-
|
|
1446
|
+
expect(odd!.className).toContain("before:bg-table-stripe");
|
|
1447
|
+
expect(even!.className).not.toContain("before:bg-table-stripe");
|
|
1398
1448
|
// Hover/selected are re-applied from the row group in both cases.
|
|
1399
|
-
expect(even!.className).toContain("group-hover/row:before:bg-
|
|
1449
|
+
expect(even!.className).toContain("group-hover/row:before:bg-table-row-hover");
|
|
1400
1450
|
expect(container.querySelector("tbody tr")!.className).toContain("group/row");
|
|
1401
1451
|
});
|
|
1402
1452
|
|
|
@@ -1411,7 +1461,7 @@ describe("DataTable — #333 pinned cells compose with the row wash, not overpai
|
|
|
1411
1461
|
);
|
|
1412
1462
|
for (const cell of pinned(container, "left")) {
|
|
1413
1463
|
expect(cell.className).toContain("bg-card");
|
|
1414
|
-
expect(cell.className).not.toContain("before:bg-
|
|
1464
|
+
expect(cell.className).not.toContain("before:bg-table-stripe");
|
|
1415
1465
|
}
|
|
1416
1466
|
// The row divider is still the separation cue and is untouched by pinning.
|
|
1417
1467
|
expect(container.querySelector("tbody tr")!.className).toContain("border-border-strong");
|
|
@@ -415,6 +415,12 @@ export interface DataTableProps<TData, TValue> extends Omit<
|
|
|
415
415
|
*/
|
|
416
416
|
zebra?: boolean;
|
|
417
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Draw a quiet `--rule` hairline between columns (header and body). Off by
|
|
420
|
+
* default. Pinned cells keep their own seam and never take a divider.
|
|
421
|
+
*/
|
|
422
|
+
columnDividers?: boolean;
|
|
423
|
+
|
|
418
424
|
// ── Row drag-reorder (#13) ───────────────────────────────────────────────
|
|
419
425
|
/**
|
|
420
426
|
* Opt-in row drag-reorder. Off by default — an existing table renders
|
|
@@ -538,6 +544,14 @@ function isActiveTextSelection(): boolean {
|
|
|
538
544
|
const PINNED_SEAM_CLASS =
|
|
539
545
|
"after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border-strong after:content-['']";
|
|
540
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Opt-in `columnDividers` hairline. `--rule`, not `--border-strong`: the column
|
|
549
|
+
* is already told apart by alignment and whitespace, so this line is a
|
|
550
|
+
* redundant boundary (ADR 0010). A real border is fine here, unlike the pinned
|
|
551
|
+
* seam above — pinned cells never take it.
|
|
552
|
+
*/
|
|
553
|
+
const COLUMN_DIVIDER_CLASS = "border-e border-rule last:border-e-0";
|
|
554
|
+
|
|
541
555
|
/**
|
|
542
556
|
* Ids of leaf columns whose ORIGINAL `ColumnDef` declares no `size` (#333).
|
|
543
557
|
*
|
|
@@ -835,6 +849,7 @@ function DataTableInner<TData, TValue>(
|
|
|
835
849
|
maxBodyHeight = "32rem",
|
|
836
850
|
|
|
837
851
|
zebra = true,
|
|
852
|
+
columnDividers = false,
|
|
838
853
|
|
|
839
854
|
// Row drag-reorder (#13)
|
|
840
855
|
enableRowReorder = false,
|
|
@@ -1776,7 +1791,7 @@ function DataTableInner<TData, TValue>(
|
|
|
1776
1791
|
// must stay byte-identical to the body's so an
|
|
1777
1792
|
// end-aligned numeric column's header lines up with its
|
|
1778
1793
|
// own values.
|
|
1779
|
-
"h-10 px-3 text-start align-middle font-
|
|
1794
|
+
"h-10 px-3 text-start align-middle font-table-header text-muted-foreground",
|
|
1780
1795
|
// #69: a numeric column's `meta` overrides the default
|
|
1781
1796
|
// `text-start` — placed right after the base string so
|
|
1782
1797
|
// tailwind-merge lets it win over that default.
|
|
@@ -1811,6 +1826,7 @@ function DataTableInner<TData, TValue>(
|
|
|
1811
1826
|
// it must not read as a "boundary + fill in one class string"
|
|
1812
1827
|
// redundancy (separation:check).
|
|
1813
1828
|
geometry?.edgeClass,
|
|
1829
|
+
columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
|
|
1814
1830
|
)}
|
|
1815
1831
|
>
|
|
1816
1832
|
{header.isPlaceholder ? null : canSort ? (
|
|
@@ -1967,17 +1983,25 @@ function DataTableInner<TData, TValue>(
|
|
|
1967
1983
|
* under virtualization (a CSS `even:`/`odd:` variant would "swim" as the
|
|
1968
1984
|
* windowed `<tr>`s recycle).
|
|
1969
1985
|
*
|
|
1970
|
-
* - zebra (default): a gentle `
|
|
1986
|
+
* - zebra (default): a gentle `--table-stripe` wash on alternate rows is the ONE
|
|
1971
1987
|
* separation gesture; rows carry NO divider (#173's strong divider was the cue
|
|
1972
1988
|
* only because nothing else was — the stripe replaces it, so a border would now
|
|
1973
|
-
* be redundant per the surface-separation rule).
|
|
1989
|
+
* be redundant per the surface-separation rule). A theme that turns the stripe
|
|
1990
|
+
* off (`--table-stripe: transparent`) sets `--table-row-rule-width` to put the
|
|
1991
|
+
* strong divider back as the sole cue; it is `0px` by default, so the stock
|
|
1992
|
+
* stripe carries no border and no extra pixel.
|
|
1974
1993
|
* - lines (`zebra={false}`): the classic `border-border-strong` divider between
|
|
1975
1994
|
* rows; `last:border-b-0` so the final divider doesn't double with the
|
|
1976
1995
|
* container's own bottom border (which reads as a heavy edge / shadow).
|
|
1977
1996
|
*/
|
|
1978
1997
|
function rowSeparationClass(rowIndex: number): string {
|
|
1979
1998
|
if (!zebra) return "border-b border-border-strong last:border-b-0";
|
|
1980
|
-
return
|
|
1999
|
+
return cn(
|
|
2000
|
+
"border-b-(length:--table-row-rule-width) border-border-strong last:border-b-0",
|
|
2001
|
+
// Separate cn() argument: the stripe and the (theme-gated) rule are
|
|
2002
|
+
// alternative cues, never both at once — see the jsdoc above.
|
|
2003
|
+
rowIndex % 2 === 1 && "bg-table-stripe",
|
|
2004
|
+
);
|
|
1981
2005
|
}
|
|
1982
2006
|
|
|
1983
2007
|
/**
|
|
@@ -2015,8 +2039,8 @@ function DataTableInner<TData, TValue>(
|
|
|
2015
2039
|
return cn(
|
|
2016
2040
|
"bg-card",
|
|
2017
2041
|
"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']",
|
|
2018
|
-
zebra && rowIndex % 2 === 1 && "before:bg-
|
|
2019
|
-
"group-hover/row:before:bg-
|
|
2042
|
+
zebra && rowIndex % 2 === 1 && "before:bg-table-stripe",
|
|
2043
|
+
"group-hover/row:before:bg-table-row-hover",
|
|
2020
2044
|
"group-data-[state=selected]/row:before:bg-accent",
|
|
2021
2045
|
);
|
|
2022
2046
|
}
|
|
@@ -2084,7 +2108,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2084
2108
|
// (only movement is neutralized); the gated duration-fast/ease-standard
|
|
2085
2109
|
// pair already collapses toward ~0ms via --motion-factor when the user
|
|
2086
2110
|
// or OS asks for reduced motion, matching the header sort button.
|
|
2087
|
-
"transition-colors duration-fast ease-standard hover:bg-
|
|
2111
|
+
"transition-colors duration-fast ease-standard hover:bg-table-row-hover data-[state=selected]:bg-accent",
|
|
2088
2112
|
// #13: the dragged row's live `transform` (set inline via `extras.style`,
|
|
2089
2113
|
// see `SortableDataRow`) is what actually MOVES it — this class only
|
|
2090
2114
|
// makes that movement glide instead of snapping, through the gated
|
|
@@ -2153,6 +2177,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2153
2177
|
geometry && pinnedCellFillClass(rowIndex),
|
|
2154
2178
|
// Separate cn() argument — see pinnedCellGeometry's edgeClass.
|
|
2155
2179
|
geometry?.edgeClass,
|
|
2180
|
+
columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
|
|
2156
2181
|
)}
|
|
2157
2182
|
>
|
|
2158
2183
|
{clickable && cellIndex === 0 && (
|
|
@@ -2199,7 +2224,11 @@ function DataTableInner<TData, TValue>(
|
|
|
2199
2224
|
{visibleColumns.map((column) => (
|
|
2200
2225
|
<td
|
|
2201
2226
|
key={column.id}
|
|
2202
|
-
className={cn(
|
|
2227
|
+
className={cn(
|
|
2228
|
+
"px-3 py-2 align-middle",
|
|
2229
|
+
numericColumnClasses(column.columnDef.meta),
|
|
2230
|
+
columnDividers && COLUMN_DIVIDER_CLASS,
|
|
2231
|
+
)}
|
|
2203
2232
|
>
|
|
2204
2233
|
<Skeleton className="h-4 w-full" />
|
|
2205
2234
|
</td>
|
|
@@ -2403,8 +2432,13 @@ function DataTableInner<TData, TValue>(
|
|
|
2403
2432
|
<div
|
|
2404
2433
|
ref={scrollRef}
|
|
2405
2434
|
tabIndex={0}
|
|
2406
|
-
// Names the focus stop (WCAG 4.1.2)
|
|
2407
|
-
//
|
|
2435
|
+
// Names the focus stop (WCAG 4.1.2). A naming-capable role is required
|
|
2436
|
+
// for that name to compute at all — `aria-label` on a plain `<div>`
|
|
2437
|
+
// (role `generic`) is not guaranteed to produce an accessible name.
|
|
2438
|
+
// `group`, not `region`: a landmark per table would be redundant over
|
|
2439
|
+
// the real <table> and collide under axe `landmark-unique` when two
|
|
2440
|
+
// tables share a page.
|
|
2441
|
+
role="group"
|
|
2408
2442
|
aria-label={t("data.table.scrollRegion")}
|
|
2409
2443
|
aria-busy={loading || undefined}
|
|
2410
2444
|
className="relative overflow-auto rounded-lg border bg-card focus-ring"
|
|
@@ -2422,7 +2456,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2422
2456
|
className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
|
|
2423
2457
|
>
|
|
2424
2458
|
<Spinner aria-hidden="true" className="text-foreground" />
|
|
2425
|
-
<span className="sr-only">
|
|
2459
|
+
<span className="sr-only">{t("data.table.loading")}</span>
|
|
2426
2460
|
</div>
|
|
2427
2461
|
)}
|
|
2428
2462
|
<table
|
|
@@ -2468,7 +2502,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2468
2502
|
className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
|
|
2469
2503
|
>
|
|
2470
2504
|
<Spinner aria-hidden="true" className="text-foreground" />
|
|
2471
|
-
<span className="sr-only">
|
|
2505
|
+
<span className="sr-only">{t("data.table.loading")}</span>
|
|
2472
2506
|
</div>
|
|
2473
2507
|
)}
|
|
2474
2508
|
{/* The tab stop exists ONLY while the region measurably overflows: without
|
|
@@ -2476,13 +2510,18 @@ function DataTableInner<TData, TValue>(
|
|
|
2476
2510
|
axe `scrollable-region-focusable`) — but adding it unconditionally would
|
|
2477
2511
|
give every table that FITS a focus stop that does nothing and announces
|
|
2478
2512
|
"scrollable" when it isn't. `aria-label` moves with it (WCAG 4.1.2:
|
|
2479
|
-
a name for a stop that exists, none for one that doesn't)
|
|
2480
|
-
`role="
|
|
2481
|
-
|
|
2513
|
+
a name for a stop that exists, none for one that doesn't) — and
|
|
2514
|
+
`role="group"` moves with BOTH of them: `aria-label` on a plain
|
|
2515
|
+
`<div>` (role `generic`) is not guaranteed to compute into an
|
|
2516
|
+
accessible name, so the stop needs a naming-capable role. `group`,
|
|
2517
|
+
never the `region` landmark: that would be redundant over the real
|
|
2518
|
+
<table> and collide (axe `landmark-unique`) with every other
|
|
2519
|
+
overflowing table on the page. */}
|
|
2482
2520
|
<div
|
|
2483
2521
|
ref={plainScrollRef}
|
|
2484
2522
|
data-slot="data-table-scroll-region"
|
|
2485
2523
|
tabIndex={scrollOverflows ? 0 : undefined}
|
|
2524
|
+
role={scrollOverflows ? "group" : undefined}
|
|
2486
2525
|
aria-label={scrollOverflows ? t("data.table.scrollRegion") : undefined}
|
|
2487
2526
|
onScroll={updateScrollAffordance}
|
|
2488
2527
|
className="overflow-auto rounded-lg focus-ring-inset"
|
|
@@ -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
|
|
|
@@ -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,8 +82,8 @@ export function SearchInput({
|
|
|
57
82
|
{value && !disabled ? (
|
|
58
83
|
<button
|
|
59
84
|
type="button"
|
|
60
|
-
onClick={
|
|
61
|
-
aria-label="
|
|
85
|
+
onClick={handleClear}
|
|
86
|
+
aria-label={t("data.searchInput.clear")}
|
|
62
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
|
|
@@ -78,4 +103,4 @@ export function SearchInput({
|
|
|
78
103
|
) : null}
|
|
79
104
|
</div>
|
|
80
105
|
);
|
|
81
|
-
}
|
|
106
|
+
});
|
package/src/to-csv.test.ts
CHANGED
|
@@ -82,6 +82,19 @@ describe("toCsv", () => {
|
|
|
82
82
|
expect(csv).toContain("'@baz");
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it("does NOT quote a plain negative number as an injection risk (#review false positive)", () => {
|
|
86
|
+
const rows = [{ val: -5 } as unknown as Row];
|
|
87
|
+
const csv = toCsv(rows);
|
|
88
|
+
expect(csv).toContain("-5");
|
|
89
|
+
expect(csv).not.toContain("'-5");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not quote a leading-plus number either", () => {
|
|
93
|
+
const rows = [{ val: "+12.5" } as Row];
|
|
94
|
+
const csv = toCsv(rows);
|
|
95
|
+
expect(csv).not.toContain("'+12.5");
|
|
96
|
+
});
|
|
97
|
+
|
|
85
98
|
it("null → empty string", () => {
|
|
86
99
|
const rows = [{ val: null } as Row];
|
|
87
100
|
const csv = toCsv(rows);
|