@elabs-ai/components-data 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-3OVN7MMO.js +2 -0
  3. package/dist/chunk-3OVN7MMO.js.map +1 -0
  4. package/dist/chunk-3UFWYVDS.js +2 -0
  5. package/dist/chunk-3UFWYVDS.js.map +1 -0
  6. package/dist/chunk-47ZBQZYS.js +47 -0
  7. package/dist/chunk-47ZBQZYS.js.map +1 -0
  8. package/dist/chunk-5K2IFOV2.js +2 -0
  9. package/dist/chunk-5K2IFOV2.js.map +1 -0
  10. package/dist/chunk-677ORWI6.js +17 -0
  11. package/dist/chunk-677ORWI6.js.map +1 -0
  12. package/dist/chunk-7ZYUY3UL.js +2 -0
  13. package/dist/chunk-7ZYUY3UL.js.map +1 -0
  14. package/dist/chunk-BEYHV3ZX.js +37 -0
  15. package/dist/chunk-BEYHV3ZX.js.map +1 -0
  16. package/dist/chunk-DBPJ7LVM.js +57 -0
  17. package/dist/chunk-DBPJ7LVM.js.map +1 -0
  18. package/dist/chunk-E3V3SYY7.js +87 -0
  19. package/dist/chunk-E3V3SYY7.js.map +1 -0
  20. package/dist/chunk-GBO56756.js +81 -0
  21. package/dist/chunk-GBO56756.js.map +1 -0
  22. package/dist/chunk-HG2YMSRM.js +95 -0
  23. package/dist/chunk-HG2YMSRM.js.map +1 -0
  24. package/dist/chunk-K2GHSRCP.js +1823 -0
  25. package/dist/chunk-K2GHSRCP.js.map +1 -0
  26. package/dist/chunk-KCB33PD3.js +43 -0
  27. package/dist/chunk-KCB33PD3.js.map +1 -0
  28. package/dist/chunk-MV2YVXEZ.js +67 -0
  29. package/dist/chunk-MV2YVXEZ.js.map +1 -0
  30. package/dist/chunk-NKVJUQYW.js +82 -0
  31. package/dist/chunk-NKVJUQYW.js.map +1 -0
  32. package/dist/chunk-OASM53LL.js +29 -0
  33. package/dist/chunk-OASM53LL.js.map +1 -0
  34. package/dist/chunk-RZ5MYZOA.js +2 -0
  35. package/dist/chunk-RZ5MYZOA.js.map +1 -0
  36. package/dist/chunk-SK6347WB.js +100 -0
  37. package/dist/chunk-SK6347WB.js.map +1 -0
  38. package/dist/chunk-TBB33PHQ.js +58 -0
  39. package/dist/chunk-TBB33PHQ.js.map +1 -0
  40. package/dist/chunk-VCLA45IC.js +150 -0
  41. package/dist/chunk-VCLA45IC.js.map +1 -0
  42. package/dist/chunk-XN6O6Z2N.js +58 -0
  43. package/dist/chunk-XN6O6Z2N.js.map +1 -0
  44. package/dist/chunk-ZFEW36RG.js +77 -0
  45. package/dist/chunk-ZFEW36RG.js.map +1 -0
  46. package/dist/chunk-ZQXJ34VJ.js +34 -0
  47. package/dist/chunk-ZQXJ34VJ.js.map +1 -0
  48. package/dist/chunk-ZUCGV2XD.js +42 -0
  49. package/dist/chunk-ZUCGV2XD.js.map +1 -0
  50. package/dist/column-picker/column-picker.js +8 -0
  51. package/dist/column-picker/column-picker.js.map +1 -0
  52. package/dist/column-picker/index.js +9 -0
  53. package/dist/column-picker/index.js.map +1 -0
  54. package/dist/data-table/card-layout.js +11 -0
  55. package/dist/data-table/card-layout.js.map +1 -0
  56. package/dist/data-table/cell-scales.js +26 -0
  57. package/dist/data-table/cell-scales.js.map +1 -0
  58. package/dist/data-table/cells/bar-cell.js +14 -0
  59. package/dist/data-table/cells/bar-cell.js.map +1 -0
  60. package/dist/data-table/cells/category-legend.js +9 -0
  61. package/dist/data-table/cells/category-legend.js.map +1 -0
  62. package/dist/data-table/cells/columns-cell.js +13 -0
  63. package/dist/data-table/cells/columns-cell.js.map +1 -0
  64. package/dist/data-table/cells/heatmap-cell.js +13 -0
  65. package/dist/data-table/cells/heatmap-cell.js.map +1 -0
  66. package/dist/data-table/cells/markdown-cell.js +13 -0
  67. package/dist/data-table/cells/markdown-cell.js.map +1 -0
  68. package/dist/data-table/cells/sparkline-cell.js +13 -0
  69. package/dist/data-table/cells/sparkline-cell.js.map +1 -0
  70. package/dist/data-table/column-meta.js +12 -0
  71. package/dist/data-table/column-meta.js.map +1 -0
  72. package/dist/data-table/data-table.js +23 -0
  73. package/dist/data-table/data-table.js.map +1 -0
  74. package/dist/data-table/index.js +23 -0
  75. package/dist/data-table/index.js.map +1 -0
  76. package/dist/data-table/ranks-column.js +13 -0
  77. package/dist/data-table/ranks-column.js.map +1 -0
  78. package/dist/data-table/sticky-rows.js +10 -0
  79. package/dist/data-table/sticky-rows.js.map +1 -0
  80. package/dist/data-table/use-table-breakpoint.js +13 -0
  81. package/dist/data-table/use-table-breakpoint.js.map +1 -0
  82. package/dist/facet-filter/facet-filter.js +8 -0
  83. package/dist/facet-filter/facet-filter.js.map +1 -0
  84. package/dist/facet-filter/index.js +9 -0
  85. package/dist/facet-filter/index.js.map +1 -0
  86. package/dist/filter-bar/filter-bar.js +8 -0
  87. package/dist/filter-bar/filter-bar.js.map +1 -0
  88. package/dist/filter-bar/filter-chip.js +9 -0
  89. package/dist/filter-bar/filter-chip.js.map +1 -0
  90. package/dist/filter-bar/index.js +13 -0
  91. package/dist/filter-bar/index.js.map +1 -0
  92. package/dist/index.d.ts +554 -10
  93. package/dist/index.js +102 -1494
  94. package/dist/index.js.map +1 -1
  95. package/dist/search-input/index.js +9 -0
  96. package/dist/search-input/index.js.map +1 -0
  97. package/dist/search-input/search-input.js +9 -0
  98. package/dist/search-input/search-input.js.map +1 -0
  99. package/dist/to-csv.js +10 -0
  100. package/dist/to-csv.js.map +1 -0
  101. package/package.json +7 -7
  102. package/src/__contract__/filter-chip.contract.test.tsx +49 -0
  103. package/src/column-picker/column-picker.tsx +3 -2
  104. package/src/data-table/card-layout.test.tsx +26 -0
  105. package/src/data-table/card-layout.tsx +74 -0
  106. package/src/data-table/cell-scales.test.ts +118 -0
  107. package/src/data-table/cell-scales.ts +246 -0
  108. package/src/data-table/cells/bar-cell.test.tsx +158 -0
  109. package/src/data-table/cells/bar-cell.tsx +135 -0
  110. package/src/data-table/cells/category-legend.test.tsx +37 -0
  111. package/src/data-table/cells/category-legend.tsx +58 -0
  112. package/src/data-table/cells/columns-cell.test.tsx +27 -0
  113. package/src/data-table/cells/columns-cell.tsx +79 -0
  114. package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
  115. package/src/data-table/cells/heatmap-cell.tsx +116 -0
  116. package/src/data-table/cells/markdown-cell.test.tsx +36 -0
  117. package/src/data-table/cells/markdown-cell.tsx +91 -0
  118. package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
  119. package/src/data-table/cells/sparkline-cell.tsx +113 -0
  120. package/src/data-table/column-meta.test.ts +42 -0
  121. package/src/data-table/column-meta.ts +282 -0
  122. package/src/data-table/data-table.stories.tsx +748 -1
  123. package/src/data-table/data-table.test.tsx +547 -11
  124. package/src/data-table/data-table.tsx +1014 -175
  125. package/src/data-table/ranks-column.test.tsx +65 -0
  126. package/src/data-table/ranks-column.tsx +91 -0
  127. package/src/data-table/sticky-rows.test.ts +16 -0
  128. package/src/data-table/sticky-rows.ts +77 -0
  129. package/src/data-table/use-table-breakpoint.test.ts +14 -0
  130. package/src/data-table/use-table-breakpoint.ts +67 -0
  131. package/src/facet-filter/facet-filter.stories.tsx +4 -1
  132. package/src/facet-filter/facet-filter.test.tsx +3 -3
  133. package/src/filter-bar/filter-chip.stories.tsx +1 -0
  134. package/src/index.ts +64 -0
  135. package/src/search-input/search-input.test.tsx +35 -0
  136. package/src/search-input/search-input.tsx +44 -19
  137. package/src/templates-data-app.stories.tsx +7 -2
  138. package/src/to-csv.test.ts +13 -0
  139. package/src/to-csv.ts +7 -30
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { DataTableRankCell, DataTableRankHeader, computeRowRanks } from "./ranks-column";
4
+
5
+ describe("ranks-column", () => {
6
+ it("computeRowRanks numbers rows 1…n in data order, skipping excluded (sticky) rows", () => {
7
+ const ranks = computeRowRanks(["a", "avg", "b", "c"], new Set(["avg"]));
8
+ expect([...ranks.entries()]).toEqual([
9
+ ["a", 1],
10
+ ["b", 2],
11
+ ["c", 3],
12
+ ]);
13
+ });
14
+ it("renders a column header and a rank cell", () => {
15
+ render(
16
+ <table>
17
+ <thead>
18
+ <tr>
19
+ <DataTableRankHeader />
20
+ </tr>
21
+ </thead>
22
+ <tbody>
23
+ <tr>
24
+ <DataTableRankCell rank="3" />
25
+ </tr>
26
+ </tbody>
27
+ </table>,
28
+ );
29
+ expect(screen.getByRole("columnheader")).toHaveTextContent("#");
30
+ expect(screen.getByRole("cell")).toHaveTextContent("3");
31
+ });
32
+ /**
33
+ * b-5 — the printed number is the row's position in the DATA, which is why
34
+ * it reads 2, 1, 6, 4 beside a sorted column. A header whose accessible name
35
+ * is the single character "#" never says so, so the rule is unlearnable:
36
+ * the name has to BE the rule. Exact string, per the conventions' "never a
37
+ * name regex" note.
38
+ */
39
+ it("names the column by what the number means, not by the glyph", () => {
40
+ render(
41
+ <table>
42
+ <thead>
43
+ <tr>
44
+ <DataTableRankHeader />
45
+ </tr>
46
+ </thead>
47
+ </table>,
48
+ );
49
+ const header = screen.getByRole("columnheader");
50
+ expect(header).toHaveAccessibleName("Position in the data as supplied");
51
+ expect(header).toHaveAttribute("title", "Position in the data as supplied");
52
+ });
53
+ it("takes a caller's own name for the column", () => {
54
+ render(
55
+ <table>
56
+ <thead>
57
+ <tr>
58
+ <DataTableRankHeader label="Rank in the 2024 census" />
59
+ </tr>
60
+ </thead>
61
+ </table>,
62
+ );
63
+ expect(screen.getByRole("columnheader")).toHaveAccessibleName("Rank in the 2024 census");
64
+ });
65
+ });
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type TdHTMLAttributes, type ThHTMLAttributes } from "react";
4
+ import { useLocale } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+
7
+ /**
8
+ * ranks-column.tsx — `DataTable`'s `showRanks` column (RM-123).
9
+ *
10
+ * A rank belongs to the ROW: it is the row's 1-based position in the `data`
11
+ * you passed (sticky rows excluded), so re-sorting, filtering or paging moves
12
+ * the numbers with their rows and never renumbers them. It is rendered beside
13
+ * the TanStack columns, not as one, so the table model is unchanged.
14
+ */
15
+
16
+ /** Row id → rank (1…n in data order), skipping the ids in `exclude`. */
17
+ export function computeRowRanks(
18
+ rowIds: readonly string[],
19
+ exclude: ReadonlySet<string> = new Set(),
20
+ ): Map<string, number> {
21
+ const ranks = new Map<string, number>();
22
+ let rank = 0;
23
+ for (const id of rowIds) {
24
+ if (!exclude.has(id)) ranks.set(id, ++rank);
25
+ }
26
+ return ranks;
27
+ }
28
+
29
+ export interface DataTableRankHeaderProps extends ThHTMLAttributes<HTMLTableCellElement> {
30
+ /**
31
+ * The column's accessible NAME, and its `title`. Defaults to the locale
32
+ * seam's `data.table.rankHeader`.
33
+ *
34
+ * The visible glyph stays "#" — it has to, in a 40px column — so the glyph
35
+ * is decorative (`aria-hidden`) and this string is what the column is
36
+ * actually called. Without it the header's accessible name is the single
37
+ * character "#", which says nothing about WHICH position is printed: beside
38
+ * a sorted column the numbers legitimately read 2, 1, 6, 4 and a reader has
39
+ * no way to learn why. `DataTable` prints the same rule visibly, once, above
40
+ * the table (`data-slot="data-table-rank-key"`).
41
+ */
42
+ label?: string;
43
+ }
44
+
45
+ /**
46
+ * The ranks column's header cell: "#" as the visible glyph, `label` as the
47
+ * name AT reads.
48
+ */
49
+ export const DataTableRankHeader = forwardRef<HTMLTableCellElement, DataTableRankHeaderProps>(
50
+ function DataTableRankHeader({ className, children, label, ...props }, ref) {
51
+ const { t } = useLocale();
52
+ const name = label ?? t("data.table.rankHeader");
53
+ return (
54
+ <th
55
+ ref={ref}
56
+ scope="col"
57
+ title={name}
58
+ data-slot="data-table-rank-header"
59
+ className={cn("w-10 px-3 text-end align-middle tabular-nums", className)}
60
+ {...props}
61
+ >
62
+ {children ?? (
63
+ <>
64
+ <span aria-hidden="true">#</span>
65
+ <span className="sr-only">{name}</span>
66
+ </>
67
+ )}
68
+ </th>
69
+ );
70
+ },
71
+ );
72
+
73
+ /** One row's rank cell; `rank` undefined (a sticky row) renders empty. */
74
+ export const DataTableRankCell = forwardRef<
75
+ HTMLTableCellElement,
76
+ TdHTMLAttributes<HTMLTableCellElement> & { rank?: string }
77
+ >(function DataTableRankCell({ rank, className, ...props }, ref) {
78
+ return (
79
+ <td
80
+ ref={ref}
81
+ data-slot="data-table-rank-cell"
82
+ className={cn(
83
+ "w-10 px-3 text-end align-middle tabular-nums text-muted-foreground",
84
+ className,
85
+ )}
86
+ {...props}
87
+ >
88
+ {rank}
89
+ </td>
90
+ );
91
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { stickyRowPinning } from "./sticky-rows";
3
+
4
+ describe("stickyRowPinning", () => {
5
+ const data = [{ n: "a" }, { n: "avg" }, { n: "b" }, { n: "total" }];
6
+ it("maps records to TanStack row ids (index, or getRowId) in data order", () => {
7
+ const sticky = (r: { n: string }) =>
8
+ r.n === "avg" ? "top" : r.n === "total" ? "bottom" : undefined;
9
+ expect(stickyRowPinning(data, sticky)).toEqual({ top: ["1"], bottom: ["3"] });
10
+ expect(stickyRowPinning(data, sticky, (r) => r.n)).toEqual({ top: ["avg"], bottom: ["total"] });
11
+ });
12
+ it("is empty without a predicate or a match", () => {
13
+ expect(stickyRowPinning(data, undefined)).toEqual({ top: [], bottom: [] });
14
+ expect(stickyRowPinning(data, () => undefined)).toEqual({ top: [], bottom: [] });
15
+ });
16
+ });
@@ -0,0 +1,77 @@
1
+ import type { RowModel, RowPinningState, Table } from "@tanstack/react-table";
2
+
3
+ /**
4
+ * sticky-rows.ts — `DataTable`'s `stickyRows` (RM-123): rows such as an
5
+ * "average" or "total" that stay at the top or bottom of EVERY page and after
6
+ * every sort.
7
+ *
8
+ * Built on TanStack's own row pinning (`keepPinnedRows`), so the table model is
9
+ * unchanged: a sticky row keeps its id, its selection and its `data` index; it
10
+ * is only rendered outside the sorted / paged centre rows.
11
+ */
12
+
13
+ export type DataTableStickyRowPosition = "top" | "bottom";
14
+
15
+ /** Decides, per record, whether its row sticks to the top or the bottom (or neither). */
16
+ export type DataTableStickyRows<TData> = (
17
+ row: TData,
18
+ index: number,
19
+ ) => DataTableStickyRowPosition | undefined;
20
+
21
+ const EMPTY: RowPinningState = { top: [], bottom: [] };
22
+
23
+ /**
24
+ * The TanStack `rowPinning` state for `stickyRows` over `data`, in data order.
25
+ * Row ids follow TanStack's own rule: `getRowId(row, index)`, else the index.
26
+ */
27
+ export function stickyRowPinning<TData>(
28
+ data: readonly TData[],
29
+ stickyRows: DataTableStickyRows<TData> | undefined,
30
+ getRowId?: (row: TData, index: number) => string,
31
+ ): RowPinningState {
32
+ if (!stickyRows) return EMPTY;
33
+ const top: string[] = [];
34
+ const bottom: string[] = [];
35
+ data.forEach((row, index) => {
36
+ const position = stickyRows(row, index);
37
+ if (!position) return;
38
+ const id = getRowId ? getRowId(row, index) : String(index);
39
+ (position === "top" ? top : bottom).push(id);
40
+ });
41
+ return top.length === 0 && bottom.length === 0 ? EMPTY : { top, bottom };
42
+ }
43
+
44
+ /**
45
+ * Wraps a filtered-row-model factory so sticky (pinned) rows leave the flow the
46
+ * table sorts and pages: every page holds `pageSize` ordinary rows and the page
47
+ * count ignores the sticky ones, which `keepPinnedRows` still renders at the
48
+ * top / bottom of every page. Sticky rows are also exempt from search and
49
+ * filters — an "average" row describes the whole table, not the matches.
50
+ */
51
+ export function withoutStickyRows<TData>(
52
+ factory: (table: Table<TData>) => () => RowModel<TData>,
53
+ ): (table: Table<TData>) => () => RowModel<TData> {
54
+ return (table) => {
55
+ const compute = factory(table);
56
+ let lastModel: RowModel<TData> | undefined;
57
+ let lastPinning: RowPinningState | undefined;
58
+ let lastResult: RowModel<TData> | undefined;
59
+ return () => {
60
+ const model = compute();
61
+ const pinning = table.getState().rowPinning;
62
+ if (model === lastModel && pinning === lastPinning && lastResult) return lastResult;
63
+ lastModel = model;
64
+ lastPinning = pinning;
65
+ const sticky = new Set([...(pinning?.top ?? []), ...(pinning?.bottom ?? [])]);
66
+ if (sticky.size === 0) {
67
+ lastResult = model;
68
+ return model;
69
+ }
70
+ const rows = model.rows.filter((row) => !sticky.has(row.id));
71
+ const flatRows = model.flatRows.filter((row) => !sticky.has(row.id));
72
+ const rowsById = Object.fromEntries(flatRows.map((row) => [row.id, row]));
73
+ lastResult = { rows, flatRows, rowsById };
74
+ return lastResult;
75
+ };
76
+ };
77
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DATA_TABLE_BREAKPOINT_THRESHOLDS, tableBreakpointForWidth } from "./use-table-breakpoint";
3
+
4
+ describe("tableBreakpointForWidth", () => {
5
+ it("is narrow below 450 px; the boundary and unmeasured widths are wide", () => {
6
+ expect(DATA_TABLE_BREAKPOINT_THRESHOLDS.narrow).toBe(450);
7
+ expect(tableBreakpointForWidth(348)).toBe("narrow");
8
+ expect(tableBreakpointForWidth(449.5)).toBe("narrow");
9
+ expect(tableBreakpointForWidth(450)).toBe("wide");
10
+ expect(tableBreakpointForWidth(640)).toBe("wide");
11
+ expect(tableBreakpointForWidth(0)).toBe("wide");
12
+ expect(tableBreakpointForWidth(Number.NaN)).toBe("wide");
13
+ });
14
+ });
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ /**
4
+ * use-table-breakpoint.ts — `DataTable`'s own container-measured breakpoint.
5
+ *
6
+ * A DELIBERATE COPY of `charts`' `useMeasuredChartBreakpoint`
7
+ * (`packages/charts/src/charts/chart-breakpoint.ts`, ADR 0039): `data` may not
8
+ * import `charts` (one-way dependency graph), and a table's threshold is its
9
+ * own. Keep the two in step when either changes; `.claude/rules/data.md`
10
+ * carries the same table.
11
+ *
12
+ * | Helper | Package | Tiers | Thresholds (container px) |
13
+ * | --------------------------- | ------- | ------------------------- | ------------------------- |
14
+ * | `useMeasuredChartBreakpoint` | charts | narrow / medium / wide | narrow < 480, medium < 768 |
15
+ * | `useTableBreakpoint` | data | narrow / wide | narrow < 450 |
16
+ *
17
+ * Measured on the table's OWN width, never the viewport. A width of `0` (not
18
+ * measured yet: server render, first pass, `display: none`) is `wide`, so a
19
+ * first paint matches the pre-breakpoint table. A boundary belongs to the
20
+ * wider tier (450 → wide).
21
+ */
22
+
23
+ import { useCallback, useLayoutEffect, useState } from "react";
24
+
25
+ /** A table container's width tier. */
26
+ export type DataTableBreakpoint = "narrow" | "wide";
27
+
28
+ /** A width (CSS px) below `narrow` is narrow. */
29
+ export const DATA_TABLE_BREAKPOINT_THRESHOLDS = { narrow: 450 } as const;
30
+
31
+ /** The tier for a measured container width. */
32
+ export function tableBreakpointForWidth(width: number): DataTableBreakpoint {
33
+ if (!Number.isFinite(width) || width <= 0) return "wide";
34
+ return width < DATA_TABLE_BREAKPOINT_THRESHOLDS.narrow ? "narrow" : "wide";
35
+ }
36
+
37
+ /**
38
+ * Measures the element the returned `ref` is attached to with a
39
+ * `ResizeObserver` and returns its tier. Re-renders only when the TIER changes.
40
+ * `enabled: false` skips the observer entirely (a table that uses no
41
+ * breakpoint behaviour pays nothing).
42
+ */
43
+ export function useTableBreakpoint<E extends Element = HTMLDivElement>(
44
+ enabled = true,
45
+ ): { ref: (node: E | null) => void; breakpoint: DataTableBreakpoint } {
46
+ const [node, setNode] = useState<E | null>(null);
47
+ const [breakpoint, setBreakpoint] = useState<DataTableBreakpoint>("wide");
48
+ const ref = useCallback((next: E | null) => setNode(next), []);
49
+
50
+ useLayoutEffect(() => {
51
+ if (!enabled || !node) return undefined;
52
+ const update = (width: number) => {
53
+ const next = tableBreakpointForWidth(width);
54
+ setBreakpoint((prev) => (prev === next ? prev : next));
55
+ };
56
+ update(node.getBoundingClientRect().width);
57
+ if (typeof ResizeObserver === "undefined") return undefined;
58
+ const observer = new ResizeObserver((entries) => {
59
+ const entry = entries[entries.length - 1];
60
+ if (entry) update(entry.contentRect.width);
61
+ });
62
+ observer.observe(node);
63
+ return () => observer.disconnect();
64
+ }, [enabled, node]);
65
+
66
+ return { ref, breakpoint: enabled ? breakpoint : "wide" };
67
+ }
@@ -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
 
@@ -137,6 +137,7 @@ export const LongLabelWithCount: Story = {
137
137
  };
138
138
 
139
139
  export const WithCountHighDecoration: Story = {
140
+ tags: ["!dev"],
140
141
  name: "With count — high decoration",
141
142
  globals: { decoration: "10" },
142
143
  args: { count: 1204, countLabel: "excluded" },
package/src/index.ts CHANGED
@@ -25,3 +25,67 @@ export type {
25
25
 
26
26
  // CSV helpers — pure, dependency-free serializer + browser download trigger.
27
27
  export * from "./to-csv";
28
+
29
+ // DataTable presentation layer — RM-123: typed column meta (visuals, format,
30
+ // colorBy, showAt, sizing, markdown), the in-cell visuals, the shared cell
31
+ // scales, the table breakpoint, card layout, sticky rows and ranks.
32
+ export {
33
+ columnSizeStyle,
34
+ formatCellValue,
35
+ resolveShowAt,
36
+ type DataTableBarVisual,
37
+ type DataTableCellVisual,
38
+ type DataTableColorBy,
39
+ type DataTableColumnsVisual,
40
+ type DataTableHeatmapScale,
41
+ type DataTableHeatmapVisual,
42
+ type DataTableMarkdownOptions,
43
+ type DataTableShowAt,
44
+ type DataTableSparklineVisual,
45
+ type DataTableValueFormatSpec,
46
+ } from "./data-table/column-meta";
47
+ export {
48
+ barDomain,
49
+ barGeometry,
50
+ computeColumnScales,
51
+ extentOf,
52
+ heatmapColorScaleSpec,
53
+ seriesValues,
54
+ type DataTableColumnScale,
55
+ type NumericExtent,
56
+ } from "./data-table/cell-scales";
57
+ export {
58
+ DATA_TABLE_BREAKPOINT_THRESHOLDS,
59
+ tableBreakpointForWidth,
60
+ useTableBreakpoint,
61
+ type DataTableBreakpoint,
62
+ } from "./data-table/use-table-breakpoint";
63
+ export {
64
+ stickyRowPinning,
65
+ type DataTableStickyRowPosition,
66
+ type DataTableStickyRows,
67
+ } from "./data-table/sticky-rows";
68
+ export { DataTableRankCell, DataTableRankHeader, computeRowRanks } from "./data-table/ranks-column";
69
+ export {
70
+ DataTableCard,
71
+ DataTableCardList,
72
+ type DataTableCardField,
73
+ type DataTableCardProps,
74
+ } from "./data-table/card-layout";
75
+ export { BarCell, type BarCellProps } from "./data-table/cells/bar-cell";
76
+ export { CategoryLegend, type CategoryLegendProps } from "./data-table/cells/category-legend";
77
+ export { SparklineCell, type SparklineCellProps } from "./data-table/cells/sparkline-cell";
78
+ export { ColumnsCell, type ColumnsCellProps } from "./data-table/cells/columns-cell";
79
+ export {
80
+ HeatmapCell,
81
+ HeatmapLegend,
82
+ heatmapCellStyle,
83
+ type HeatmapCellProps,
84
+ type HeatmapLegendProps,
85
+ } from "./data-table/cells/heatmap-cell";
86
+ export {
87
+ MarkdownCell,
88
+ isSafeCellUrl,
89
+ parseCellMarkdown,
90
+ type MarkdownCellProps,
91
+ } from "./data-table/cells/markdown-cell";
@@ -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
+ });
@@ -9,8 +9,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
9
9
  import { useState } from "react";
10
10
  import {
11
11
  Badge,
12
+ NavUser,
12
13
  Sidebar,
13
14
  SidebarContent,
15
+ SidebarFooter,
14
16
  SidebarGroup,
15
17
  SidebarGroupContent,
16
18
  SidebarHeader,
@@ -108,9 +110,12 @@ function DataAppTemplate({ loading = false }: { loading?: boolean }) {
108
110
  </SidebarGroupContent>
109
111
  </SidebarGroup>
110
112
  </SidebarContent>
113
+ <SidebarFooter>
114
+ <NavUser user={{ name: "Avery Rao", email: "avery@example.com" }} />
115
+ </SidebarFooter>
111
116
  </Sidebar>
112
117
  <SidebarInset>
113
- <header className="flex h-14 items-center gap-2 border-b px-4">
118
+ <header className="flex h-header items-center gap-2 border-b px-4">
114
119
  <SidebarTrigger />
115
120
  <h1 className="text-body font-medium capitalize">{active}</h1>
116
121
  </header>
@@ -146,7 +151,7 @@ function DataAppTemplate({ loading = false }: { loading?: boolean }) {
146
151
  }
147
152
 
148
153
  const meta = {
149
- title: "Patterns/Templates/Data App",
154
+ title: "Patterns/Templates/Starters/Data App",
150
155
  parameters: { layout: "fullscreen" },
151
156
  tags: ["autodocs"],
152
157
  } satisfies Meta;
@@ -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);