@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elabs-ai/components-data",
3
- "version": "4.1.0",
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.1.0",
38
- "@elabs-ai/components-ui": "4.1.0",
39
- "@elabs-ai/components-tokens": "4.1.0"
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.1.0"
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 = "Columns", className, ...props }: ColumnPickerProps<TData>,
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
- {label}
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-foreground/5");
596
- expect(rows[1]?.className).toContain("bg-foreground/5");
597
- expect(rows[2]?.className).not.toContain("bg-foreground/5");
598
- // No row carries a divider (a border on a striped region would be redundant).
599
- rows.forEach((r) => expect(r.className).not.toContain("border-b"));
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-foreground/5");
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-foreground/5");
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-foreground/5");
1397
- expect(even!.className).not.toContain("before:bg-foreground/5");
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-foreground/10");
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-foreground/5");
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-medium text-muted-foreground",
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 `foreground/5` wash on alternate rows is the ONE
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 rowIndex % 2 === 1 ? "bg-foreground/5" : "";
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-foreground/5",
2019
- "group-hover/row:before:bg-foreground/10",
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-foreground/10 data-[state=selected]:bg-accent",
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("px-3 py-2 align-middle", numericColumnClasses(column.columnDef.meta))}
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) without a landmark role a `role="region"`
2407
- // here would add a redundant landmark over the inner real <table>.
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">Loading table data…</span>
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">Loading table data…</span>
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). No
2480
- `role="region"` that would add a redundant landmark over the real
2481
- <table> inside it. */}
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
- <DatePicker placeholder="Pick a date" />
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-9) lines up.
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-9");
71
- expect(trigger).not.toHaveClass("h-8");
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
- value,
30
- onValueChange,
31
- label = "Search",
32
- placeholder = "Search…",
33
- className,
34
- containerClassName,
35
- disabled,
36
- ...props
37
- }: SearchInputProps) {
38
- const id = useId();
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
- {label}
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={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={() => onValueChange("")}
61
- aria-label="Clear search"
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
+ });
@@ -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);