@elabs-ai/components-data 4.2.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 (131) 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 +547 -8
  93. package/dist/index.js +102 -1512
  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/data-table/card-layout.test.tsx +26 -0
  103. package/src/data-table/card-layout.tsx +74 -0
  104. package/src/data-table/cell-scales.test.ts +118 -0
  105. package/src/data-table/cell-scales.ts +246 -0
  106. package/src/data-table/cells/bar-cell.test.tsx +158 -0
  107. package/src/data-table/cells/bar-cell.tsx +135 -0
  108. package/src/data-table/cells/category-legend.test.tsx +37 -0
  109. package/src/data-table/cells/category-legend.tsx +58 -0
  110. package/src/data-table/cells/columns-cell.test.tsx +27 -0
  111. package/src/data-table/cells/columns-cell.tsx +79 -0
  112. package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
  113. package/src/data-table/cells/heatmap-cell.tsx +116 -0
  114. package/src/data-table/cells/markdown-cell.test.tsx +36 -0
  115. package/src/data-table/cells/markdown-cell.tsx +91 -0
  116. package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
  117. package/src/data-table/cells/sparkline-cell.tsx +113 -0
  118. package/src/data-table/column-meta.test.ts +42 -0
  119. package/src/data-table/column-meta.ts +282 -0
  120. package/src/data-table/data-table.stories.tsx +733 -1
  121. package/src/data-table/data-table.test.tsx +486 -0
  122. package/src/data-table/data-table.tsx +965 -165
  123. package/src/data-table/ranks-column.test.tsx +65 -0
  124. package/src/data-table/ranks-column.tsx +91 -0
  125. package/src/data-table/sticky-rows.test.ts +16 -0
  126. package/src/data-table/sticky-rows.ts +77 -0
  127. package/src/data-table/use-table-breakpoint.test.ts +14 -0
  128. package/src/data-table/use-table-breakpoint.ts +67 -0
  129. package/src/filter-bar/filter-chip.stories.tsx +1 -0
  130. package/src/index.ts +64 -0
  131. package/src/templates-data-app.stories.tsx +7 -2
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import { barGeometry } from "../cell-scales";
6
+
7
+ /**
8
+ * A positive bar's fill: the diverging ramp's positive end — the brand-hue arm
9
+ * (117.6) that MIRRORS `BAR_CELL_NEGATIVE_COLOR`, so the two arms of a signed
10
+ * column are drawn at the same weight instead of one shouting over the other.
11
+ *
12
+ * NOT `--chart-1` (the first series colour) any more. `--chart-1` is a
13
+ * CATEGORICAL token — it answers "which series", it carries no contrast
14
+ * guarantee, and on `light` it is a signed-off 1.4.11 exemption (see
15
+ * `CHART_1411_EXEMPT` in packages/tokens/src/charts-contrast.test.ts): it
16
+ * measures 1.42:1 on `--card` and 1.30:1 on the bar's own `bg-muted` track, so
17
+ * the bar read as a wash exactly where its LENGTH is the whole message. The
18
+ * diverging ramp is the one gated ≥3:1 in every theme, including its mid step —
19
+ * measured 8.99:1 on `--card` in light and dark alike.
20
+ */
21
+ export const BAR_CELL_POSITIVE_COLOR = "var(--chart-div-pos-2)";
22
+ /** A negative bar's fill: the diverging ramp's negative end. */
23
+ export const BAR_CELL_NEGATIVE_COLOR = "var(--chart-div-neg-2)";
24
+
25
+ export interface BarCellProps extends HTMLAttributes<HTMLDivElement> {
26
+ /** The number the bar draws; `null` draws no bar. */
27
+ value: number | null;
28
+ /** The printed (formatted) value. Always in the accessible tree. */
29
+ label: string;
30
+ /** `[lo, hi]` of the track; always contains zero (see `barDomain`). */
31
+ domain: readonly [number, number];
32
+ /** `"regular"` (default): bar beside the value; `"slim"`: a thin bar under it. */
33
+ variant?: "regular" | "slim";
34
+ /** Paint the grey remainder of the track. */
35
+ track?: boolean;
36
+ /** A category colour (`var(--chart-N)`) that replaces the positive fill. */
37
+ fillColor?: string | null;
38
+ /** Paint a negative value in the negative token. Default `true`. */
39
+ negativeColor?: boolean;
40
+ /**
41
+ * Room reserved for the printed value, in `ch` (`labelBoxCh` over the whole
42
+ * column). Every bar in a column then shares one track length, so bar lengths
43
+ * compare down the column and a diverging column's zero rule keeps one x.
44
+ * Unset (or `"slim"`, where the bar sits under the value) leaves the value
45
+ * box to size itself.
46
+ */
47
+ labelWidth?: number;
48
+ }
49
+
50
+ /**
51
+ * An in-cell bar (CSS only). The bar grows from zero — toward inline-end for a
52
+ * positive value, toward inline-start for a negative one — inside a track that
53
+ * spans the column's shared domain. The value stays printed beside it.
54
+ */
55
+ export const BarCell = forwardRef<HTMLDivElement, BarCellProps>(function BarCell(
56
+ {
57
+ value,
58
+ label,
59
+ domain,
60
+ variant = "regular",
61
+ track = false,
62
+ fillColor,
63
+ negativeColor = true,
64
+ labelWidth,
65
+ className,
66
+ ...props
67
+ },
68
+ ref,
69
+ ) {
70
+ const geometry = barGeometry(value ?? Number.NaN, domain);
71
+ const fill =
72
+ geometry.negative && negativeColor
73
+ ? BAR_CELL_NEGATIVE_COLOR
74
+ : (fillColor ?? BAR_CELL_POSITIVE_COLOR);
75
+ const straddlesZero = domain[0] < 0 && domain[1] > 0;
76
+ const slim = variant === "slim";
77
+ return (
78
+ <div
79
+ ref={ref}
80
+ data-slot="bar-cell"
81
+ data-variant={variant}
82
+ data-negative={geometry.negative || undefined}
83
+ className={cn(
84
+ "flex min-w-0",
85
+ slim ? "flex-col items-stretch gap-1" : "items-center gap-2",
86
+ className,
87
+ )}
88
+ {...props}
89
+ >
90
+ {/*
91
+ The value box is a column-wide reservation (`labelWidth`), never this
92
+ row's own text width: `shrink-0` plus an explicit `width` keeps every
93
+ track in the column the same length, whatever the number reads.
94
+ */}
95
+ <span
96
+ data-slot="bar-cell-value"
97
+ className={cn("shrink-0 tabular-nums", !slim && "whitespace-nowrap text-end")}
98
+ style={!slim && labelWidth ? { width: `${labelWidth}ch` } : undefined}
99
+ >
100
+ {label}
101
+ </span>
102
+ <span
103
+ aria-hidden="true"
104
+ data-slot="bar-cell-track"
105
+ className={cn(
106
+ // The track grows along the PARENT's main axis, which the variant
107
+ // flips: a regular bar sits beside its value (row -> `flex-1` takes
108
+ // the spare width), a slim bar sits under it (column -> `flex-1`
109
+ // would resolve the HEIGHT to flex-basis 0 and erase the mark, so it
110
+ // spans the width and keeps its own height instead).
111
+ "relative block min-w-8 rounded-sm",
112
+ slim ? "h-1 w-full shrink-0" : "h-3 flex-1",
113
+ track && "bg-muted",
114
+ )}
115
+ >
116
+ {straddlesZero && (
117
+ <span
118
+ data-slot="bar-cell-zero"
119
+ className="absolute -inset-y-0.5 w-px bg-border-strong"
120
+ style={{ insetInlineStart: `${geometry.zero}%` }}
121
+ />
122
+ )}
123
+ <span
124
+ data-slot="bar-cell-bar"
125
+ className="absolute inset-y-0 rounded-sm"
126
+ style={{
127
+ insetInlineStart: `${geometry.start}%`,
128
+ width: `${geometry.size}%`,
129
+ backgroundColor: fill,
130
+ }}
131
+ />
132
+ </span>
133
+ </div>
134
+ );
135
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { colorScaleFor } from "@elabs-ai/components-ui";
4
+ import { CategoryLegend } from "./category-legend";
5
+
6
+ const regions = colorScaleFor(["North", "South", "East", "South"], {
7
+ type: "stepped",
8
+ palette: "categorical",
9
+ });
10
+
11
+ describe("CategoryLegend", () => {
12
+ it("names every category beside its swatch, as one named group", () => {
13
+ const { container } = render(<CategoryLegend scale={regions} title="Region" />);
14
+ const group = screen.getByRole("group", { name: "Region" });
15
+ expect(group).toBeInTheDocument();
16
+ expect([...group.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
17
+ "North",
18
+ "South",
19
+ "East",
20
+ ]);
21
+ // The swatch is the colour the cells are painted in, so a reader can match
22
+ // the two; it is decorative because the name beside it carries the meaning.
23
+ const swatches = container.querySelectorAll<HTMLElement>(
24
+ '[data-slot="category-legend-swatch"]',
25
+ );
26
+ expect(swatches).toHaveLength(3);
27
+ expect([...swatches].map((s) => s.style.backgroundColor)).toEqual(
28
+ regions.categories.map((c) => c.color),
29
+ );
30
+ expect(swatches[0]).toHaveAttribute("aria-hidden", "true");
31
+ });
32
+ it("renders nothing for a scale with no categories", () => {
33
+ const numeric = colorScaleFor([1, 2, 3], { type: "continuous" });
34
+ const { container } = render(<CategoryLegend scale={numeric} title="Rides" />);
35
+ expect(container.querySelector('[data-slot="category-legend"]')).toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
4
+ import type { ColorScale } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+
7
+ export interface CategoryLegendProps extends HTMLAttributes<HTMLDivElement> {
8
+ /** A categorical scale — its `categories`, in first-seen order. */
9
+ scale: ColorScale;
10
+ /** What the colours divide the rows BY (the `colorBy` key, or a nicer name). */
11
+ title?: string;
12
+ /** Print a category value; defaults to `String`. */
13
+ formatCategory?: (value: string | number) => string;
14
+ }
15
+
16
+ /**
17
+ * A `colorBy` column's colour key: one named swatch per category.
18
+ *
19
+ * A categorical fill answers "which group", and unlike a bar's length or a
20
+ * heatmap's ramp there is nothing in the cell to read it off. Without a key the
21
+ * category is carried by hue alone — unreadable to anyone who cannot separate
22
+ * those hues, and unnameable by everyone else (WCAG 1.4.1). This is the same
23
+ * key `HeatmapLegend` gives an ordered scale, for an unordered one.
24
+ */
25
+ export const CategoryLegend = forwardRef<HTMLDivElement, CategoryLegendProps>(
26
+ function CategoryLegend({ scale, title, formatCategory = String, className, ...props }, ref) {
27
+ if (scale.categories.length === 0) return null;
28
+ return (
29
+ <div
30
+ ref={ref}
31
+ role="group"
32
+ aria-label={title}
33
+ data-slot="category-legend"
34
+ className={cn("flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-meta", className)}
35
+ {...props}
36
+ >
37
+ {title && (
38
+ <span aria-hidden="true" className="text-muted-foreground">
39
+ {title}
40
+ </span>
41
+ )}
42
+ <ul className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
43
+ {scale.categories.map((category) => (
44
+ <li key={String(category.value)} className="flex items-center gap-1">
45
+ <span
46
+ aria-hidden="true"
47
+ data-slot="category-legend-swatch"
48
+ className="block size-2.5 rounded-full"
49
+ style={{ backgroundColor: category.color }}
50
+ />
51
+ <span>{formatCategory(category.value)}</span>
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ </div>
56
+ );
57
+ },
58
+ );
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { ColumnsCell } from "./columns-cell";
4
+
5
+ describe("ColumnsCell", () => {
6
+ it("draws one column per present value from a zero rule, heights never negative", () => {
7
+ const { container, getByText } = render(
8
+ <ColumnsCell
9
+ values={[
10
+ { key: "q1", value: 2 },
11
+ { key: "q2", value: null },
12
+ { key: "q3", value: -1 },
13
+ ]}
14
+ domain={{ min: -1, max: 4 }}
15
+ label="2, –, -1"
16
+ height={20}
17
+ />,
18
+ );
19
+ expect(getByText("2, –, -1")).toHaveClass("sr-only");
20
+ const rects = container.querySelectorAll("rect");
21
+ expect(rects).toHaveLength(2);
22
+ rects.forEach((r) => expect(Number(r.getAttribute("height"))).toBeGreaterThanOrEqual(0));
23
+ // 2 on [-1, 4] over 20 px → 8 px tall; -1 → 4 px.
24
+ expect(Number(rects[0]?.getAttribute("height"))).toBeCloseTo(8);
25
+ expect(Number(rects[1]?.getAttribute("height"))).toBeCloseTo(4);
26
+ });
27
+ });
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { NumericExtent } from "../cell-scales";
6
+ import { BAR_CELL_NEGATIVE_COLOR, BAR_CELL_POSITIVE_COLOR } from "./bar-cell";
7
+
8
+ /** The zero rule's width (px) — the charts hairline, mirrored. */
9
+ export const COLUMNS_CELL_HAIRLINE_WIDTH = 0.65;
10
+ const VIEW_WIDTH = 100;
11
+ const GAP = 12; // % of a slot left empty between columns
12
+
13
+ export interface ColumnsCellProps extends HTMLAttributes<HTMLDivElement> {
14
+ /** One entry per row key, in x order; a `null` value draws no column. */
15
+ values: readonly { key: string; value: number | null }[];
16
+ /** The value domain: this row's own extent or the column's. Zero is always added. */
17
+ domain: NumericExtent | null;
18
+ /** Every value as text, for screen readers, sorting and copy. */
19
+ label: string;
20
+ /** Drawing height in px. Default 24. */
21
+ height?: number;
22
+ }
23
+
24
+ /** In-cell mini columns: a ≤ 40-line inline SVG, columns grow from a zero rule. */
25
+ export const ColumnsCell = forwardRef<HTMLDivElement, ColumnsCellProps>(function ColumnsCell(
26
+ { values, domain, label, height = 24, className, ...props },
27
+ ref,
28
+ ) {
29
+ const h = Math.max(0, height);
30
+ const lo = Math.min(0, domain?.min ?? 0);
31
+ const hi = Math.max(0, domain?.max ?? 0);
32
+ const span = hi - lo;
33
+ const y = (v: number) => (span > 0 ? ((hi - v) / span) * h : h);
34
+ const slot = values.length > 0 ? VIEW_WIDTH / values.length : 0;
35
+ const width = Math.max(0, slot * (1 - GAP / 100));
36
+ return (
37
+ <div
38
+ ref={ref}
39
+ data-slot="columns-cell"
40
+ className={cn("flex min-w-0 items-center", className)}
41
+ {...props}
42
+ >
43
+ <span className="sr-only">{label}</span>
44
+ <svg
45
+ aria-hidden="true"
46
+ data-slot="columns-cell-svg"
47
+ data-y-min={lo}
48
+ data-y-max={hi}
49
+ viewBox={`0 0 ${VIEW_WIDTH} ${h}`}
50
+ preserveAspectRatio="none"
51
+ className="block min-w-8 flex-1"
52
+ style={{ height: h }}
53
+ >
54
+ {values.map(({ key, value: v }, i) =>
55
+ v === null ? null : (
56
+ <rect
57
+ key={key}
58
+ data-slot="columns-cell-column"
59
+ x={i * slot + (slot - width) / 2}
60
+ y={Math.min(y(v), y(0))}
61
+ width={width}
62
+ height={Math.max(0, Math.abs(y(v) - y(0)))}
63
+ fill={v < 0 ? BAR_CELL_NEGATIVE_COLOR : BAR_CELL_POSITIVE_COLOR}
64
+ />
65
+ ),
66
+ )}
67
+ <line
68
+ x1={0}
69
+ x2={VIEW_WIDTH}
70
+ y1={y(0)}
71
+ y2={y(0)}
72
+ stroke="var(--chart-grid)"
73
+ strokeWidth={COLUMNS_CELL_HAIRLINE_WIDTH}
74
+ vectorEffect="non-scaling-stroke"
75
+ />
76
+ </svg>
77
+ </div>
78
+ );
79
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { colorScaleFor } from "@elabs-ai/components-ui";
4
+ import { HeatmapCell, HeatmapLegend, heatmapCellStyle } from "./heatmap-cell";
5
+
6
+ describe("HeatmapCell", () => {
7
+ it("keeps the value for AT when hidden, and paints the td with a ramp token", () => {
8
+ render(<HeatmapCell label="42" hideValue />);
9
+ expect(screen.getByText("42")).toHaveClass("sr-only");
10
+ expect(heatmapCellStyle("var(--chart-seq-3)")).toEqual({
11
+ backgroundColor: "var(--chart-seq-3)",
12
+ });
13
+ expect(heatmapCellStyle(null)).toBeUndefined();
14
+ });
15
+ it("legend: one swatch per stepped class, a ramp strip for continuous", () => {
16
+ const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
17
+ const { container, rerender } = render(
18
+ <HeatmapLegend
19
+ scale={colorScaleFor(values, { type: "stepped", steps: 4 })}
20
+ title="Rides"
21
+ formatValue={String}
22
+ />,
23
+ );
24
+ expect(screen.getByRole("group", { name: "Rides" })).toBeInTheDocument();
25
+ expect(container.querySelectorAll('[data-slot="heatmap-legend-swatch"]')).toHaveLength(4);
26
+ rerender(
27
+ <HeatmapLegend scale={colorScaleFor(values, { type: "continuous" })} formatValue={String} />,
28
+ );
29
+ expect(container.querySelector('[data-slot="heatmap-legend-ramp"]')).not.toBeNull();
30
+ });
31
+ });
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type CSSProperties, type HTMLAttributes } from "react";
4
+ import type { ColorScale } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+
7
+ /**
8
+ * The `<td>` style that paints a heatmap cell: the value's ramp colour (a
9
+ * `var(--chart-seq-*)` / `var(--chart-div-*)` reference from `colorScaleFor`),
10
+ * so a theme flip re-colours it with no re-render. `null` (no data) paints
11
+ * nothing.
12
+ */
13
+ export function heatmapCellStyle(color: string | null): CSSProperties | undefined {
14
+ return color ? { backgroundColor: color } : undefined;
15
+ }
16
+
17
+ export interface HeatmapCellProps extends HTMLAttributes<HTMLSpanElement> {
18
+ /** The printed (formatted) value. Always in the accessible tree. */
19
+ label: string;
20
+ /** Hide the value visually; it stays for screen readers, sorting and copy. */
21
+ hideValue?: boolean;
22
+ }
23
+
24
+ /**
25
+ * A heatmap cell's content. The `<td>` carries the colour
26
+ * ({@link heatmapCellStyle}); a visible value sits on a card plate, so its ink
27
+ * reads on every ramp step in every theme without resolving the colour.
28
+ */
29
+ export const HeatmapCell = forwardRef<HTMLSpanElement, HeatmapCellProps>(function HeatmapCell(
30
+ { label, hideValue = false, className, ...props },
31
+ ref,
32
+ ) {
33
+ return (
34
+ <span
35
+ ref={ref}
36
+ data-slot="heatmap-cell"
37
+ className={cn(
38
+ hideValue
39
+ ? "sr-only"
40
+ : "inline-block rounded-sm bg-card/85 px-1 text-foreground tabular-nums",
41
+ className,
42
+ )}
43
+ {...props}
44
+ >
45
+ {label}
46
+ </span>
47
+ );
48
+ });
49
+
50
+ export interface HeatmapLegendProps extends HTMLAttributes<HTMLDivElement> {
51
+ scale: ColorScale;
52
+ /** What the colours measure (the column's header). */
53
+ title?: string;
54
+ formatValue: (value: number) => string;
55
+ }
56
+
57
+ /**
58
+ * A heatmap column's colour key: one swatch per class (stepped) or a ramp
59
+ * strip (continuous), with the class bounds printed in the `text-meta` role.
60
+ */
61
+ export const HeatmapLegend = forwardRef<HTMLDivElement, HeatmapLegendProps>(function HeatmapLegend(
62
+ { scale, title, formatValue, className, ...props },
63
+ ref,
64
+ ) {
65
+ const first = scale.steps[0];
66
+ const last = scale.steps[scale.steps.length - 1];
67
+ if (!first || !last) return null;
68
+ const continuous = scale.type === "continuous";
69
+ return (
70
+ <div
71
+ ref={ref}
72
+ role="group"
73
+ aria-label={title}
74
+ data-slot="heatmap-legend"
75
+ data-scale-type={scale.type}
76
+ className={cn("flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-meta", className)}
77
+ {...props}
78
+ >
79
+ {title && (
80
+ <span aria-hidden="true" className="text-muted-foreground">
81
+ {title}
82
+ </span>
83
+ )}
84
+ {continuous ? (
85
+ <span className="flex min-w-0 items-center gap-1.5 tabular-nums">
86
+ <span>{formatValue(first.from)}</span>
87
+ <span
88
+ aria-hidden="true"
89
+ data-slot="heatmap-legend-ramp"
90
+ className="block h-2.5 w-24 rounded-sm"
91
+ style={{
92
+ backgroundImage: `linear-gradient(to right, ${scale.steps.map((s) => s.color).join(", ")})`,
93
+ }}
94
+ />
95
+ <span>{formatValue(last.to)}</span>
96
+ </span>
97
+ ) : (
98
+ <ul className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 tabular-nums">
99
+ {scale.steps.map((step) => (
100
+ <li key={`${step.from}-${step.to}`} className="flex items-center gap-1">
101
+ <span
102
+ aria-hidden="true"
103
+ data-slot="heatmap-legend-swatch"
104
+ className="block size-2.5 rounded-full"
105
+ style={{ backgroundColor: step.color }}
106
+ />
107
+ <span>
108
+ {formatValue(step.from)}–{formatValue(step.to)}
109
+ </span>
110
+ </li>
111
+ ))}
112
+ </ul>
113
+ )}
114
+ </div>
115
+ );
116
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { MarkdownCell, isSafeCellUrl } from "./markdown-cell";
4
+
5
+ describe("MarkdownCell", () => {
6
+ it("renders the safe inline subset as elements", () => {
7
+ const { container } = render(
8
+ <MarkdownCell text="**Oslo** is *big*, see [docs](https://example.com) and `x`, 10^2^" />,
9
+ );
10
+ expect(container.querySelector("strong")).toHaveTextContent("Oslo");
11
+ expect(container.querySelector("em")).toHaveTextContent("big");
12
+ expect(screen.getByRole("link", { name: "docs" })).toHaveAttribute(
13
+ "href",
14
+ "https://example.com",
15
+ );
16
+ expect(container.querySelector("code")).toHaveTextContent("x");
17
+ expect(container.querySelector("sup")).toHaveTextContent("2");
18
+ });
19
+ it("never links an unsafe URL and leaves raw HTML as text", () => {
20
+ const { container } = render(<MarkdownCell text="[x](javascript:alert(1)) <b>y</b>" />);
21
+ expect(container.querySelector("a")).toBeNull();
22
+ expect(container.querySelector("b")).toBeNull();
23
+ expect(container.textContent).toContain("<b>y</b>");
24
+ expect(isSafeCellUrl("//evil.example")).toBe(false);
25
+ expect(isSafeCellUrl("/path")).toBe(true);
26
+ });
27
+ it("images are off by default (alt text prints) and on with images", () => {
28
+ const { container, rerender } = render(
29
+ <MarkdownCell text="![flag](https://example.com/f.png)" />,
30
+ );
31
+ expect(container.querySelector("img")).toBeNull();
32
+ expect(container).toHaveTextContent("flag");
33
+ rerender(<MarkdownCell text="![flag](https://example.com/f.png)" images />);
34
+ expect(container.querySelector("img")).toHaveAttribute("alt", "flag");
35
+ });
36
+ });
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { Fragment, forwardRef, type HTMLAttributes, type ReactNode } from "react";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+
6
+ /**
7
+ * Safe inline markdown for a table cell: `**bold**`, `*italic*` / `_italic_`,
8
+ * `` `code` ``, `[link](url)`, `^sup^` / `<sup>sup</sup>`, and — only with
9
+ * `images: true` — `![alt](src)`. Built as React elements, never HTML: there is
10
+ * no `innerHTML` sink, and a link or image URL that is not `http(s):`,
11
+ * `mailto:`, a path or a fragment renders as plain text.
12
+ */
13
+ const TOKEN =
14
+ /\*\*([^*]+)\*\*|\*([^*]+)\*|_([^_]+)_|`([^`]+)`|!\[([^\]]*)\]\(([^)\s]+)\)|\[([^\]]+)\]\(([^)\s]+)\)|\^([^^]+)\^|<sup>(.*?)<\/sup>/g;
15
+
16
+ /** `true` for a URL a cell may link to or load. */
17
+ export function isSafeCellUrl(url: string): boolean {
18
+ return /^(https?:\/\/|mailto:|\/(?!\/)|#|\.{1,2}\/)/i.test(url.trim());
19
+ }
20
+
21
+ /** Parses `text` into React nodes. Exported for tests. */
22
+ export function parseCellMarkdown(text: string, images = false, keyPrefix = "md"): ReactNode[] {
23
+ const out: ReactNode[] = [];
24
+ let last = 0;
25
+ let n = 0;
26
+ for (const m of text.matchAll(TOKEN)) {
27
+ const at = m.index ?? 0;
28
+ if (at > last) out.push(text.slice(last, at));
29
+ const key = `${keyPrefix}-${n++}`;
30
+ const [, bold, em1, em2, code, alt, src, linkText, href, sup1, sup2] = m;
31
+ if (bold !== undefined)
32
+ out.push(<strong key={key}>{parseCellMarkdown(bold, images, key)}</strong>);
33
+ else if ((em1 ?? em2) !== undefined)
34
+ out.push(<em key={key}>{parseCellMarkdown((em1 ?? em2) as string, images, key)}</em>);
35
+ else if (code !== undefined)
36
+ out.push(
37
+ <code key={key} className="rounded-sm bg-muted px-1 font-mono text-code">
38
+ {code}
39
+ </code>,
40
+ );
41
+ else if (src !== undefined)
42
+ out.push(
43
+ images && isSafeCellUrl(src) ? (
44
+ <img
45
+ key={key}
46
+ src={src}
47
+ alt={alt ?? ""}
48
+ className="inline-block h-4 w-auto align-text-bottom"
49
+ />
50
+ ) : (
51
+ <Fragment key={key}>{alt}</Fragment>
52
+ ),
53
+ );
54
+ else if (href !== undefined && linkText !== undefined)
55
+ out.push(
56
+ isSafeCellUrl(href) ? (
57
+ <a
58
+ key={key}
59
+ href={href}
60
+ className="rounded-sm text-link underline underline-offset-2 hover:text-foreground focus-ring"
61
+ >
62
+ {parseCellMarkdown(linkText, images, key)}
63
+ </a>
64
+ ) : (
65
+ <Fragment key={key}>{parseCellMarkdown(linkText, images, key)}</Fragment>
66
+ ),
67
+ );
68
+ else out.push(<sup key={key}>{(sup1 ?? sup2) as string}</sup>);
69
+ last = at + m[0].length;
70
+ }
71
+ if (last < text.length) out.push(text.slice(last));
72
+ return out;
73
+ }
74
+
75
+ export interface MarkdownCellProps extends HTMLAttributes<HTMLSpanElement> {
76
+ text: string;
77
+ /** Render `![alt](src)` as an image. Default `false` (the alt text prints). */
78
+ images?: boolean;
79
+ }
80
+
81
+ /** A cell rendered from safe inline markdown (see {@link parseCellMarkdown}). */
82
+ export const MarkdownCell = forwardRef<HTMLSpanElement, MarkdownCellProps>(function MarkdownCell(
83
+ { text, images = false, className, ...props },
84
+ ref,
85
+ ) {
86
+ return (
87
+ <span ref={ref} data-slot="markdown-cell" className={cn("break-words", className)} {...props}>
88
+ {parseCellMarkdown(text, images)}
89
+ </span>
90
+ );
91
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { SparklineCell } from "./sparkline-cell";
4
+
5
+ describe("SparklineCell", () => {
6
+ it("keeps every value as text and draws against the given (shared) domain", () => {
7
+ const { container, getByText } = render(
8
+ <SparklineCell values={[1, null, 4]} domain={{ min: 0, max: 8 }} label="1, –, 4" fill />,
9
+ );
10
+ expect(getByText("1, –, 4")).toHaveClass("sr-only");
11
+ const svg = container.querySelector("svg");
12
+ expect(svg).toHaveAttribute("aria-hidden", "true");
13
+ expect(svg).toHaveAttribute("data-y-max", "8");
14
+ // A null breaks the line: two move-tos.
15
+ const d = container.querySelector('[data-slot="sparkline-cell-line"]')?.getAttribute("d") ?? "";
16
+ expect(d.match(/M/g)).toHaveLength(2);
17
+ });
18
+ it("prints end labels only when asked", () => {
19
+ const { container, getByText } = render(
20
+ <SparklineCell values={[1, 4]} domain={{ min: 1, max: 4 }} label="1, 4" ends={["1", "4"]} />,
21
+ );
22
+ expect(getByText("4", { selector: "span[aria-hidden]" })).toBeInTheDocument();
23
+ expect(container.querySelectorAll("span[aria-hidden]")).toHaveLength(2);
24
+ });
25
+ it("reserves one end-label box per column, so every row draws on the same x scale", () => {
26
+ const ends = (first: string, last: string) => {
27
+ const { container } = render(
28
+ <SparklineCell
29
+ values={[1, 4]}
30
+ domain={{ min: 1, max: 4 }}
31
+ label={`${first}, ${last}`}
32
+ ends={[first, last]}
33
+ endsWidth={[3, 4]}
34
+ />,
35
+ );
36
+ return [...container.querySelectorAll<HTMLElement>("span[aria-hidden]")].map(
37
+ (span) => span.style.width,
38
+ );
39
+ };
40
+ expect(ends("90", "110")).toEqual(["3ch", "4ch"]);
41
+ expect(ends("300", "380")).toEqual(["3ch", "4ch"]);
42
+ });
43
+ });