@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.
- package/README.md +1 -1
- package/dist/chunk-3OVN7MMO.js +2 -0
- package/dist/chunk-3OVN7MMO.js.map +1 -0
- package/dist/chunk-3UFWYVDS.js +2 -0
- package/dist/chunk-3UFWYVDS.js.map +1 -0
- package/dist/chunk-47ZBQZYS.js +47 -0
- package/dist/chunk-47ZBQZYS.js.map +1 -0
- package/dist/chunk-5K2IFOV2.js +2 -0
- package/dist/chunk-5K2IFOV2.js.map +1 -0
- package/dist/chunk-677ORWI6.js +17 -0
- package/dist/chunk-677ORWI6.js.map +1 -0
- package/dist/chunk-7ZYUY3UL.js +2 -0
- package/dist/chunk-7ZYUY3UL.js.map +1 -0
- package/dist/chunk-BEYHV3ZX.js +37 -0
- package/dist/chunk-BEYHV3ZX.js.map +1 -0
- package/dist/chunk-DBPJ7LVM.js +57 -0
- package/dist/chunk-DBPJ7LVM.js.map +1 -0
- package/dist/chunk-E3V3SYY7.js +87 -0
- package/dist/chunk-E3V3SYY7.js.map +1 -0
- package/dist/chunk-GBO56756.js +81 -0
- package/dist/chunk-GBO56756.js.map +1 -0
- package/dist/chunk-HG2YMSRM.js +95 -0
- package/dist/chunk-HG2YMSRM.js.map +1 -0
- package/dist/chunk-K2GHSRCP.js +1823 -0
- package/dist/chunk-K2GHSRCP.js.map +1 -0
- package/dist/chunk-KCB33PD3.js +43 -0
- package/dist/chunk-KCB33PD3.js.map +1 -0
- package/dist/chunk-MV2YVXEZ.js +67 -0
- package/dist/chunk-MV2YVXEZ.js.map +1 -0
- package/dist/chunk-NKVJUQYW.js +82 -0
- package/dist/chunk-NKVJUQYW.js.map +1 -0
- package/dist/chunk-OASM53LL.js +29 -0
- package/dist/chunk-OASM53LL.js.map +1 -0
- package/dist/chunk-RZ5MYZOA.js +2 -0
- package/dist/chunk-RZ5MYZOA.js.map +1 -0
- package/dist/chunk-SK6347WB.js +100 -0
- package/dist/chunk-SK6347WB.js.map +1 -0
- package/dist/chunk-TBB33PHQ.js +58 -0
- package/dist/chunk-TBB33PHQ.js.map +1 -0
- package/dist/chunk-VCLA45IC.js +150 -0
- package/dist/chunk-VCLA45IC.js.map +1 -0
- package/dist/chunk-XN6O6Z2N.js +58 -0
- package/dist/chunk-XN6O6Z2N.js.map +1 -0
- package/dist/chunk-ZFEW36RG.js +77 -0
- package/dist/chunk-ZFEW36RG.js.map +1 -0
- package/dist/chunk-ZQXJ34VJ.js +34 -0
- package/dist/chunk-ZQXJ34VJ.js.map +1 -0
- package/dist/chunk-ZUCGV2XD.js +42 -0
- package/dist/chunk-ZUCGV2XD.js.map +1 -0
- package/dist/column-picker/column-picker.js +8 -0
- package/dist/column-picker/column-picker.js.map +1 -0
- package/dist/column-picker/index.js +9 -0
- package/dist/column-picker/index.js.map +1 -0
- package/dist/data-table/card-layout.js +11 -0
- package/dist/data-table/card-layout.js.map +1 -0
- package/dist/data-table/cell-scales.js +26 -0
- package/dist/data-table/cell-scales.js.map +1 -0
- package/dist/data-table/cells/bar-cell.js +14 -0
- package/dist/data-table/cells/bar-cell.js.map +1 -0
- package/dist/data-table/cells/category-legend.js +9 -0
- package/dist/data-table/cells/category-legend.js.map +1 -0
- package/dist/data-table/cells/columns-cell.js +13 -0
- package/dist/data-table/cells/columns-cell.js.map +1 -0
- package/dist/data-table/cells/heatmap-cell.js +13 -0
- package/dist/data-table/cells/heatmap-cell.js.map +1 -0
- package/dist/data-table/cells/markdown-cell.js +13 -0
- package/dist/data-table/cells/markdown-cell.js.map +1 -0
- package/dist/data-table/cells/sparkline-cell.js +13 -0
- package/dist/data-table/cells/sparkline-cell.js.map +1 -0
- package/dist/data-table/column-meta.js +12 -0
- package/dist/data-table/column-meta.js.map +1 -0
- package/dist/data-table/data-table.js +23 -0
- package/dist/data-table/data-table.js.map +1 -0
- package/dist/data-table/index.js +23 -0
- package/dist/data-table/index.js.map +1 -0
- package/dist/data-table/ranks-column.js +13 -0
- package/dist/data-table/ranks-column.js.map +1 -0
- package/dist/data-table/sticky-rows.js +10 -0
- package/dist/data-table/sticky-rows.js.map +1 -0
- package/dist/data-table/use-table-breakpoint.js +13 -0
- package/dist/data-table/use-table-breakpoint.js.map +1 -0
- package/dist/facet-filter/facet-filter.js +8 -0
- package/dist/facet-filter/facet-filter.js.map +1 -0
- package/dist/facet-filter/index.js +9 -0
- package/dist/facet-filter/index.js.map +1 -0
- package/dist/filter-bar/filter-bar.js +8 -0
- package/dist/filter-bar/filter-bar.js.map +1 -0
- package/dist/filter-bar/filter-chip.js +9 -0
- package/dist/filter-bar/filter-chip.js.map +1 -0
- package/dist/filter-bar/index.js +13 -0
- package/dist/filter-bar/index.js.map +1 -0
- package/dist/index.d.ts +554 -10
- package/dist/index.js +102 -1494
- package/dist/index.js.map +1 -1
- package/dist/search-input/index.js +9 -0
- package/dist/search-input/index.js.map +1 -0
- package/dist/search-input/search-input.js +9 -0
- package/dist/search-input/search-input.js.map +1 -0
- package/dist/to-csv.js +10 -0
- package/dist/to-csv.js.map +1 -0
- package/package.json +7 -7
- package/src/__contract__/filter-chip.contract.test.tsx +49 -0
- package/src/column-picker/column-picker.tsx +3 -2
- package/src/data-table/card-layout.test.tsx +26 -0
- package/src/data-table/card-layout.tsx +74 -0
- package/src/data-table/cell-scales.test.ts +118 -0
- package/src/data-table/cell-scales.ts +246 -0
- package/src/data-table/cells/bar-cell.test.tsx +158 -0
- package/src/data-table/cells/bar-cell.tsx +135 -0
- package/src/data-table/cells/category-legend.test.tsx +37 -0
- package/src/data-table/cells/category-legend.tsx +58 -0
- package/src/data-table/cells/columns-cell.test.tsx +27 -0
- package/src/data-table/cells/columns-cell.tsx +79 -0
- package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
- package/src/data-table/cells/heatmap-cell.tsx +116 -0
- package/src/data-table/cells/markdown-cell.test.tsx +36 -0
- package/src/data-table/cells/markdown-cell.tsx +91 -0
- package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
- package/src/data-table/cells/sparkline-cell.tsx +113 -0
- package/src/data-table/column-meta.test.ts +42 -0
- package/src/data-table/column-meta.ts +282 -0
- package/src/data-table/data-table.stories.tsx +748 -1
- package/src/data-table/data-table.test.tsx +547 -11
- package/src/data-table/data-table.tsx +1014 -175
- package/src/data-table/ranks-column.test.tsx +65 -0
- package/src/data-table/ranks-column.tsx +91 -0
- package/src/data-table/sticky-rows.test.ts +16 -0
- package/src/data-table/sticky-rows.ts +77 -0
- package/src/data-table/use-table-breakpoint.test.ts +14 -0
- package/src/data-table/use-table-breakpoint.ts +67 -0
- package/src/facet-filter/facet-filter.stories.tsx +4 -1
- package/src/facet-filter/facet-filter.test.tsx +3 -3
- package/src/filter-bar/filter-chip.stories.tsx +1 -0
- package/src/index.ts +64 -0
- package/src/search-input/search-input.test.tsx +35 -0
- package/src/search-input/search-input.tsx +44 -19
- package/src/templates-data-app.stories.tsx +7 -2
- package/src/to-csv.test.ts +13 -0
- package/src/to-csv.ts +7 -30
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { DataTableCard, DataTableCardList } from "./card-layout";
|
|
4
|
+
|
|
5
|
+
describe("card-layout", () => {
|
|
6
|
+
it("renders one list item per card with a <dl> of term → detail pairs", () => {
|
|
7
|
+
render(
|
|
8
|
+
<DataTableCardList aria-label="Cities">
|
|
9
|
+
<DataTableCard
|
|
10
|
+
fields={[
|
|
11
|
+
{ id: "city", term: "City", value: "Oslo" },
|
|
12
|
+
{ id: "pop", term: "Population", value: "709,000" },
|
|
13
|
+
]}
|
|
14
|
+
/>
|
|
15
|
+
</DataTableCardList>,
|
|
16
|
+
);
|
|
17
|
+
expect(screen.getByRole("list", { name: "Cities" })).toBeInTheDocument();
|
|
18
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
|
19
|
+
const terms = screen.getAllByRole("term");
|
|
20
|
+
expect(terms.map((t) => t.textContent)).toEqual(["City", "Population"]);
|
|
21
|
+
expect(screen.getAllByRole("definition").map((d) => d.textContent)).toEqual([
|
|
22
|
+
"Oslo",
|
|
23
|
+
"709,000",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes, type LiHTMLAttributes, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* card-layout.tsx — `DataTable`'s transposed card rows (RM-123, `layout`).
|
|
8
|
+
*
|
|
9
|
+
* Under the table's `narrow` breakpoint (`layout="auto"`) or always
|
|
10
|
+
* (`layout="cards"`), each row renders as one card: a `<dl>` whose terms are
|
|
11
|
+
* the column headers and whose details are the row's cells. Only the markup
|
|
12
|
+
* changes — the TanStack model (sorting, filtering, paging, selection) is the
|
|
13
|
+
* same instance the table uses, so the toolbar and pager keep working.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One term / detail pair of a card. */
|
|
17
|
+
export interface DataTableCardField {
|
|
18
|
+
/** Stable key: the column id. */
|
|
19
|
+
id: string;
|
|
20
|
+
term: ReactNode;
|
|
21
|
+
value: ReactNode;
|
|
22
|
+
/** Extra classes / style for the detail (alignment, a colour wash). */
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: React.CSSProperties;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The list that holds the cards. Rows are separated by a strong divider. */
|
|
28
|
+
export const DataTableCardList = forwardRef<HTMLUListElement, HTMLAttributes<HTMLUListElement>>(
|
|
29
|
+
function DataTableCardList({ className, ...props }, ref) {
|
|
30
|
+
return (
|
|
31
|
+
<ul
|
|
32
|
+
ref={ref}
|
|
33
|
+
data-slot="data-table-card-list"
|
|
34
|
+
className={cn("divide-y divide-border-strong", className)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export interface DataTableCardProps extends LiHTMLAttributes<HTMLLIElement> {
|
|
42
|
+
fields: readonly DataTableCardField[];
|
|
43
|
+
/** Content above the fields (the selection checkbox, the rank, the row action). */
|
|
44
|
+
lead?: ReactNode;
|
|
45
|
+
/** `compact` tightens the padding, like the table's `density="compact"`. */
|
|
46
|
+
density?: "default" | "compact";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One row as a card: a `<dl>` of header → cell pairs. */
|
|
50
|
+
export const DataTableCard = forwardRef<HTMLLIElement, DataTableCardProps>(function DataTableCard(
|
|
51
|
+
{ fields, lead, density = "default", className, ...props },
|
|
52
|
+
ref,
|
|
53
|
+
) {
|
|
54
|
+
return (
|
|
55
|
+
<li
|
|
56
|
+
ref={ref}
|
|
57
|
+
data-slot="data-table-card"
|
|
58
|
+
className={cn("px-3", density === "compact" ? "py-2" : "py-3", className)}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{lead}
|
|
62
|
+
<dl className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-3 gap-y-1.5">
|
|
63
|
+
{fields.map((field) => (
|
|
64
|
+
<div key={field.id} data-slot="data-table-card-field" className="contents">
|
|
65
|
+
<dt className="min-w-0 break-words text-meta text-muted-foreground">{field.term}</dt>
|
|
66
|
+
<dd className={cn("min-w-0 break-words", field.className)} style={field.style}>
|
|
67
|
+
{field.value}
|
|
68
|
+
</dd>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</dl>
|
|
72
|
+
</li>
|
|
73
|
+
);
|
|
74
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
barDomain,
|
|
4
|
+
barGeometry,
|
|
5
|
+
computeColumnScales,
|
|
6
|
+
extentOf,
|
|
7
|
+
heatmapColorScaleSpec,
|
|
8
|
+
labelBoxCh,
|
|
9
|
+
seriesEnds,
|
|
10
|
+
seriesValues,
|
|
11
|
+
} from "./cell-scales";
|
|
12
|
+
|
|
13
|
+
const rows = [
|
|
14
|
+
{ a: 10, b: -5, cat: "x", s1: 1, s2: 4 },
|
|
15
|
+
{ a: 40, b: 20, cat: "y", s1: 2, s2: 8 },
|
|
16
|
+
{ a: 20, b: 5, cat: "x", s1: 3, s2: 2 },
|
|
17
|
+
].map((original) => ({
|
|
18
|
+
original,
|
|
19
|
+
getValue: (id: string) => (original as Record<string, unknown>)[id],
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe("cell-scales", () => {
|
|
23
|
+
it("extentOf ignores non-finite values", () => {
|
|
24
|
+
expect(extentOf([3, "x", null, Number.NaN, -1, 7])).toEqual({ min: -1, max: 7 });
|
|
25
|
+
expect(extentOf(["x"])).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("labelBoxCh reserves the column's longest label, so tracks match down the column", () => {
|
|
29
|
+
expect(labelBoxCh(["+12.5 %", "-4.2 %", "+0.4 %"])).toBe(7);
|
|
30
|
+
expect(labelBoxCh([])).toBe(0);
|
|
31
|
+
// Code points, not UTF-16 units: an astral glyph counts once.
|
|
32
|
+
expect(labelBoxCh(["1 🚲"])).toBe(3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("seriesEnds takes the first and last finite values", () => {
|
|
36
|
+
expect(seriesEnds([null, 2, null, 9, null])).toEqual([2, 9]);
|
|
37
|
+
expect(seriesEnds([5])).toEqual([5, 5]);
|
|
38
|
+
expect(seriesEnds([null, null])).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("barDomain always contains zero; a fixed range wins", () => {
|
|
42
|
+
expect(barDomain("column", { min: 10, max: 40 }, null)).toEqual([0, 40]);
|
|
43
|
+
expect(barDomain("column", { min: -5, max: 20 }, null)).toEqual([-5, 20]);
|
|
44
|
+
expect(barDomain("table", { min: 10, max: 40 }, { min: -5, max: 60 })).toEqual([-5, 60]);
|
|
45
|
+
expect(barDomain([0, 100], { min: 10, max: 40 }, null)).toEqual([0, 100]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("barGeometry is proportional to the domain and grows left of zero when negative", () => {
|
|
49
|
+
expect(barGeometry(20, [0, 40])).toMatchObject({ start: 0, size: 50, negative: false });
|
|
50
|
+
expect(barGeometry(40, [0, 40]).size).toBe(100);
|
|
51
|
+
// zero sits at 20% of [-5, 20]; -5 spans 0…20%.
|
|
52
|
+
const neg = barGeometry(-5, [-5, 20]);
|
|
53
|
+
expect(neg).toMatchObject({ start: 0, size: 20, zero: 20, negative: true });
|
|
54
|
+
const pos = barGeometry(10, [-5, 20]);
|
|
55
|
+
expect(pos.start).toBe(20);
|
|
56
|
+
expect(pos.size).toBeCloseTo(40);
|
|
57
|
+
// Never negative, never NaN.
|
|
58
|
+
expect(barGeometry(Number.NaN, [0, 10]).size).toBe(0);
|
|
59
|
+
expect(barGeometry(5, [0, 0]).size).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("seriesValues reads keys in order, null where a key holds no number", () => {
|
|
63
|
+
expect(seriesValues({ a: 1, b: "x", c: 3 }, ["c", "b", "a"])).toEqual([3, null, 1]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("heatmapColorScaleSpec maps the table methods onto colorScaleFor", () => {
|
|
67
|
+
expect(heatmapColorScaleSpec({ type: "stepped" })).toMatchObject({
|
|
68
|
+
type: "stepped",
|
|
69
|
+
method: "equidistant",
|
|
70
|
+
});
|
|
71
|
+
expect(heatmapColorScaleSpec({ type: "stepped", method: "jenks", steps: 4 })).toMatchObject({
|
|
72
|
+
type: "stepped",
|
|
73
|
+
method: "jenks",
|
|
74
|
+
steps: 4,
|
|
75
|
+
});
|
|
76
|
+
expect(heatmapColorScaleSpec({ type: "continuous", method: "jenks" })).toMatchObject({
|
|
77
|
+
type: "continuous",
|
|
78
|
+
method: "natural",
|
|
79
|
+
});
|
|
80
|
+
expect(
|
|
81
|
+
heatmapColorScaleSpec({ type: "continuous", method: "quantile", steps: 4 }),
|
|
82
|
+
).toMatchObject({ type: "continuous", method: "quartiles" });
|
|
83
|
+
expect(
|
|
84
|
+
heatmapColorScaleSpec({ type: "continuous", method: "custom", breaks: [1, 2] }),
|
|
85
|
+
).toMatchObject({ type: "stepped", method: "custom", breaks: [1, 2] });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("computeColumnScales: column + table bar ranges, shared series extent, pooled heatmaps, categories", () => {
|
|
89
|
+
const scale = { type: "stepped" as const, steps: 3 };
|
|
90
|
+
const scales = computeColumnScales(
|
|
91
|
+
[
|
|
92
|
+
{ id: "a", meta: { visual: { kind: "bar", range: "table" } } },
|
|
93
|
+
{ id: "b", meta: { visual: { kind: "bar", range: "table" } } },
|
|
94
|
+
{ id: "s", meta: { visual: { kind: "sparkline", keys: ["s1", "s2"], range: "column" } } },
|
|
95
|
+
{ id: "a2", meta: { visual: { kind: "heatmap", scale } } },
|
|
96
|
+
{ id: "b2", meta: { visual: { kind: "heatmap", scale } } },
|
|
97
|
+
{ id: "cat", meta: { colorBy: { key: "cat", target: "background" } } },
|
|
98
|
+
{ id: "plain", meta: { numeric: true } },
|
|
99
|
+
],
|
|
100
|
+
rows.map((r) => ({
|
|
101
|
+
...r,
|
|
102
|
+
getValue: (id: string) => r.getValue(id === "a2" ? "a" : id === "b2" ? "b" : id),
|
|
103
|
+
})),
|
|
104
|
+
);
|
|
105
|
+
expect(scales.get("a")?.barDomain).toEqual([-5, 40]);
|
|
106
|
+
expect(scales.get("b")?.barDomain).toEqual([-5, 40]);
|
|
107
|
+
expect(scales.get("s")?.seriesExtent).toEqual({ min: 1, max: 8 });
|
|
108
|
+
// Identical heatmap specs pool their values into ONE shared scale.
|
|
109
|
+
expect(scales.get("a2")?.heatmap).toBe(scales.get("b2")?.heatmap);
|
|
110
|
+
expect(scales.get("a2")?.heatmap?.domain).toEqual([-5, 40]);
|
|
111
|
+
expect(scales.get("a2")?.heatmap?.colorOf(40)).toMatch(/^var\(--chart-seq-\d\)$/);
|
|
112
|
+
expect(scales.get("cat")?.category?.colorOf("x")).toMatch(/^var\(--chart-\d+\)$/);
|
|
113
|
+
expect(scales.get("cat")?.category?.colorOf("x")).not.toBe(
|
|
114
|
+
scales.get("cat")?.category?.colorOf("y"),
|
|
115
|
+
);
|
|
116
|
+
expect(scales.has("plain")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cell-scales.ts — the shared scales behind `DataTable`'s in-cell visuals
|
|
3
|
+
* (RM-123). Pure: no React, no DOM.
|
|
4
|
+
*
|
|
5
|
+
* Every visual cell reads ONE scale per column, computed once over the full
|
|
6
|
+
* data (never the current page, filter or sort), so a bar, a sparkline or a
|
|
7
|
+
* heatmap colour means the same thing on every page and after every sort.
|
|
8
|
+
* Colour decisions (heatmap ramps, categories) come from `colorScaleFor`
|
|
9
|
+
* (`@elabs-ai/components-ui`) — the one value → colour-token scale `charts`,
|
|
10
|
+
* `data` and `maps` share — so colours are `var(--chart-…)` references.
|
|
11
|
+
*/
|
|
12
|
+
import { colorScaleFor, type ColorScale, type ColorScaleSpec } from "@elabs-ai/components-ui";
|
|
13
|
+
import type { DataTableBarVisual, DataTableColumnMeta, DataTableHeatmapScale } from "./column-meta";
|
|
14
|
+
|
|
15
|
+
/** `[min, max]` of the finite numbers in a set. */
|
|
16
|
+
export interface NumericExtent {
|
|
17
|
+
min: number;
|
|
18
|
+
max: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The finite numbers in `values`, ignoring everything else. */
|
|
22
|
+
export function finiteNumbers(values: readonly unknown[]): number[] {
|
|
23
|
+
return values.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The extent of the finite numbers in `values`; `null` when there are none. */
|
|
27
|
+
export function extentOf(values: readonly unknown[]): NumericExtent | null {
|
|
28
|
+
const nums = finiteNumbers(values);
|
|
29
|
+
if (nums.length === 0) return null;
|
|
30
|
+
return { min: Math.min(...nums), max: Math.max(...nums) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The union of two extents (either may be `null`). */
|
|
34
|
+
export function unionExtent(
|
|
35
|
+
a: NumericExtent | null,
|
|
36
|
+
b: NumericExtent | null,
|
|
37
|
+
): NumericExtent | null {
|
|
38
|
+
if (!a) return b;
|
|
39
|
+
if (!b) return a;
|
|
40
|
+
return { min: Math.min(a.min, b.min), max: Math.max(a.max, b.max) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A bar domain always contains zero, so a bar is always measured from zero. */
|
|
44
|
+
export function barDomain(
|
|
45
|
+
range: DataTableBarVisual["range"],
|
|
46
|
+
column: NumericExtent | null,
|
|
47
|
+
table: NumericExtent | null,
|
|
48
|
+
): readonly [number, number] {
|
|
49
|
+
const source =
|
|
50
|
+
Array.isArray(range) && range.length === 2
|
|
51
|
+
? { min: Math.min(range[0], range[1]), max: Math.max(range[0], range[1]) }
|
|
52
|
+
: range === "table"
|
|
53
|
+
? table
|
|
54
|
+
: column;
|
|
55
|
+
return [Math.min(0, source?.min ?? 0), Math.max(0, source?.max ?? 0)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where a bar sits inside its track, as percentages of the track width:
|
|
60
|
+
* `start` is the inline-start edge, `size` the length. Negative values grow
|
|
61
|
+
* toward inline-start from zero. Both are clamped to 0…100.
|
|
62
|
+
*/
|
|
63
|
+
export function barGeometry(
|
|
64
|
+
value: number,
|
|
65
|
+
domain: readonly [number, number],
|
|
66
|
+
): { start: number; size: number; zero: number; negative: boolean } {
|
|
67
|
+
const [lo, hi] = domain;
|
|
68
|
+
const span = hi - lo;
|
|
69
|
+
if (!Number.isFinite(value) || span <= 0) return { start: 0, size: 0, zero: 0, negative: false };
|
|
70
|
+
const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / span) * 100));
|
|
71
|
+
const zero = pct(0);
|
|
72
|
+
const end = pct(value);
|
|
73
|
+
return {
|
|
74
|
+
start: Math.min(zero, end),
|
|
75
|
+
size: Math.max(0, Math.abs(end - zero)),
|
|
76
|
+
zero,
|
|
77
|
+
negative: value < 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How much room a column reserves for a printed label, in `ch`: the longest
|
|
83
|
+
* label's length in code points.
|
|
84
|
+
*
|
|
85
|
+
* A visual cell draws in what its printed value leaves over, so the text box
|
|
86
|
+
* must be a COLUMN-wide constant. Sized per row instead, a row with a shorter
|
|
87
|
+
* number gets a longer track and therefore draws a longer bar for a smaller
|
|
88
|
+
* value, and a diverging column's zero rule lands on a different pixel in every
|
|
89
|
+
* row. `tabular-nums` makes every digit exactly `1ch` and the separators
|
|
90
|
+
* narrower, so a reservation of the longest label's character count holds it.
|
|
91
|
+
*/
|
|
92
|
+
export function labelBoxCh(labels: readonly string[]): number {
|
|
93
|
+
let widest = 0;
|
|
94
|
+
for (const label of labels) widest = Math.max(widest, [...label].length);
|
|
95
|
+
return widest;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The first and last finite values of a series; `null` when it has none. */
|
|
99
|
+
export function seriesEnds(
|
|
100
|
+
values: readonly (number | null)[],
|
|
101
|
+
): readonly [first: number, last: number] | null {
|
|
102
|
+
const present = values.filter((v): v is number => v !== null);
|
|
103
|
+
const first = present[0];
|
|
104
|
+
const last = present[present.length - 1];
|
|
105
|
+
return first === undefined || last === undefined ? null : [first, last];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A row's numbers for `keys`, `null` where a key holds no finite number. */
|
|
109
|
+
export function seriesValues(original: unknown, keys: readonly string[]): (number | null)[] {
|
|
110
|
+
const record = (original ?? {}) as Record<string, unknown>;
|
|
111
|
+
return keys.map((key) => {
|
|
112
|
+
const v = record[key];
|
|
113
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The `ColorScaleSpec` a heatmap scale asks `colorScaleFor` for. */
|
|
118
|
+
export function heatmapColorScaleSpec(scale: DataTableHeatmapScale): ColorScaleSpec {
|
|
119
|
+
const base = {
|
|
120
|
+
...(scale.domain ? { domain: scale.domain } : null),
|
|
121
|
+
...(scale.palette ? { palette: scale.palette } : null),
|
|
122
|
+
};
|
|
123
|
+
const method = scale.method ?? "linear";
|
|
124
|
+
if (scale.type === "stepped" || method === "custom") {
|
|
125
|
+
return {
|
|
126
|
+
...base,
|
|
127
|
+
type: "stepped",
|
|
128
|
+
method: method === "linear" ? "equidistant" : method,
|
|
129
|
+
...(scale.steps !== undefined ? { steps: scale.steps } : null),
|
|
130
|
+
...(scale.breaks ? { breaks: scale.breaks } : null),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (method === "jenks") {
|
|
134
|
+
return { ...base, type: "continuous", method: "natural", steps: scale.steps ?? 5 };
|
|
135
|
+
}
|
|
136
|
+
if (method === "quantile") {
|
|
137
|
+
const steps = scale.steps ?? 5;
|
|
138
|
+
const quantile =
|
|
139
|
+
steps <= 2 ? "median" : steps <= 4 ? "quartiles" : steps <= 5 ? "quintiles" : "deciles";
|
|
140
|
+
return { ...base, type: "continuous", method: quantile };
|
|
141
|
+
}
|
|
142
|
+
return { ...base, type: "continuous", method: "linear" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Everything a column's visual cells share. Only the parts the column uses are set. */
|
|
146
|
+
export interface DataTableColumnScale {
|
|
147
|
+
/** Extent of the column's own values. */
|
|
148
|
+
extent: NumericExtent | null;
|
|
149
|
+
/** Bar visual: the resolved `[lo, hi]` (always contains zero). */
|
|
150
|
+
barDomain?: readonly [number, number];
|
|
151
|
+
/** Bar `colorBy`: the category → colour scale. */
|
|
152
|
+
barCategory?: ColorScale;
|
|
153
|
+
/** Sparkline / columns: extent of every row's series values. */
|
|
154
|
+
seriesExtent?: NumericExtent | null;
|
|
155
|
+
/**
|
|
156
|
+
* Heatmap: the value → ramp-colour scale. Heatmap columns with an identical
|
|
157
|
+
* `scale` spec SHARE one scale over all their values (one heatmap across
|
|
158
|
+
* several columns); `heatmapGroup` names it.
|
|
159
|
+
*/
|
|
160
|
+
heatmap?: ColorScale;
|
|
161
|
+
heatmapGroup?: string;
|
|
162
|
+
/** `meta.colorBy`: the category → colour scale. */
|
|
163
|
+
category?: ColorScale;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A minimal row: what `computeColumnScales` needs from a TanStack row. */
|
|
167
|
+
export interface ScaleRow {
|
|
168
|
+
original: unknown;
|
|
169
|
+
getValue: (columnId: string) => unknown;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A minimal column: its id and meta. */
|
|
173
|
+
export interface ScaleColumn {
|
|
174
|
+
id: string;
|
|
175
|
+
meta: DataTableColumnMeta | undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function categoryValues(rows: readonly ScaleRow[], key: string): (string | number | null)[] {
|
|
179
|
+
return rows.map((row) => {
|
|
180
|
+
const v = ((row.original ?? {}) as Record<string, unknown>)[key];
|
|
181
|
+
return typeof v === "string" || (typeof v === "number" && Number.isFinite(v)) ? v : null;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function categoryScale(rows: readonly ScaleRow[], key: string): ColorScale {
|
|
186
|
+
return colorScaleFor(categoryValues(rows, key), { type: "stepped", palette: "categorical" });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One scale per column that needs one (a `visual` or a `colorBy`), over ALL
|
|
191
|
+
* `rows`. Columns with neither are absent from the map.
|
|
192
|
+
*/
|
|
193
|
+
export function computeColumnScales(
|
|
194
|
+
columns: readonly ScaleColumn[],
|
|
195
|
+
rows: readonly ScaleRow[],
|
|
196
|
+
): Map<string, DataTableColumnScale> {
|
|
197
|
+
const scales = new Map<string, DataTableColumnScale>();
|
|
198
|
+
let tableBarExtent: NumericExtent | null = null;
|
|
199
|
+
const heatmapValues = new Map<string, number[]>();
|
|
200
|
+
for (const { id, meta } of columns) {
|
|
201
|
+
if (!meta?.visual && !meta?.colorBy) continue;
|
|
202
|
+
const values = rows.map((row) => row.getValue(id));
|
|
203
|
+
const scale: DataTableColumnScale = { extent: extentOf(values) };
|
|
204
|
+
const visual = meta.visual;
|
|
205
|
+
if (visual?.kind === "bar" && visual.range === "table") {
|
|
206
|
+
tableBarExtent = unionExtent(tableBarExtent, scale.extent);
|
|
207
|
+
}
|
|
208
|
+
if (visual?.kind === "bar" && visual.colorBy) {
|
|
209
|
+
scale.barCategory = categoryScale(rows, visual.colorBy);
|
|
210
|
+
}
|
|
211
|
+
if (visual?.kind === "sparkline" || visual?.kind === "columns") {
|
|
212
|
+
scale.seriesExtent = extentOf(rows.flatMap((row) => seriesValues(row.original, visual.keys)));
|
|
213
|
+
}
|
|
214
|
+
if (visual?.kind === "heatmap") {
|
|
215
|
+
const group = JSON.stringify(visual.scale);
|
|
216
|
+
scale.heatmapGroup = group;
|
|
217
|
+
const pooled = heatmapValues.get(group) ?? [];
|
|
218
|
+
pooled.push(...finiteNumbers(values));
|
|
219
|
+
heatmapValues.set(group, pooled);
|
|
220
|
+
}
|
|
221
|
+
if (meta.colorBy) scale.category = categoryScale(rows, meta.colorBy.key);
|
|
222
|
+
scales.set(id, scale);
|
|
223
|
+
}
|
|
224
|
+
// Bar domains and heatmap scales resolve last: `range: "table"` needs every
|
|
225
|
+
// bar column's extent, a heatmap group every member column's values.
|
|
226
|
+
const heatmapScales = new Map<string, ColorScale>();
|
|
227
|
+
for (const { id, meta } of columns) {
|
|
228
|
+
const scale = scales.get(id);
|
|
229
|
+
if (!scale) continue;
|
|
230
|
+
if (meta?.visual?.kind === "bar") {
|
|
231
|
+
scale.barDomain = barDomain(meta.visual.range, scale.extent, tableBarExtent);
|
|
232
|
+
}
|
|
233
|
+
if (meta?.visual?.kind === "heatmap" && scale.heatmapGroup !== undefined) {
|
|
234
|
+
let shared = heatmapScales.get(scale.heatmapGroup);
|
|
235
|
+
if (!shared) {
|
|
236
|
+
shared = colorScaleFor(
|
|
237
|
+
heatmapValues.get(scale.heatmapGroup) ?? [],
|
|
238
|
+
heatmapColorScaleSpec(meta.visual.scale),
|
|
239
|
+
);
|
|
240
|
+
heatmapScales.set(scale.heatmapGroup, shared);
|
|
241
|
+
}
|
|
242
|
+
scale.heatmap = shared;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return scales;
|
|
246
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { render } from "@testing-library/react";
|
|
5
|
+
import { oklchToHex } from "@elabs-ai/components-tokens";
|
|
6
|
+
import { BAR_CELL_NEGATIVE_COLOR, BAR_CELL_POSITIVE_COLOR, BarCell } from "./bar-cell";
|
|
7
|
+
|
|
8
|
+
describe("BarCell", () => {
|
|
9
|
+
it("keeps the printed value and sizes the bar against the domain", () => {
|
|
10
|
+
const { container, getByText } = render(
|
|
11
|
+
<BarCell value={20} label="20" domain={[0, 40]} track />,
|
|
12
|
+
);
|
|
13
|
+
expect(getByText("20")).toBeInTheDocument();
|
|
14
|
+
const bar = container.querySelector<HTMLElement>('[data-slot="bar-cell-bar"]');
|
|
15
|
+
expect(bar?.style.width).toBe("50%");
|
|
16
|
+
expect(bar?.style.backgroundColor).toBe(BAR_CELL_POSITIVE_COLOR);
|
|
17
|
+
expect(container.querySelector('[data-slot="bar-cell-track"]')).toHaveClass("bg-muted");
|
|
18
|
+
});
|
|
19
|
+
it("paints a negative value in the negative token, left of zero, with a zero rule", () => {
|
|
20
|
+
const { container } = render(<BarCell value={-5} label="-5" domain={[-5, 20]} />);
|
|
21
|
+
const bar = container.querySelector<HTMLElement>('[data-slot="bar-cell-bar"]');
|
|
22
|
+
expect(bar?.style.backgroundColor).toBe(BAR_CELL_NEGATIVE_COLOR);
|
|
23
|
+
expect(bar?.style.insetInlineStart).toBe("0%");
|
|
24
|
+
expect(bar?.style.width).toBe("20%");
|
|
25
|
+
expect(container.querySelector('[data-slot="bar-cell-zero"]')).not.toBeNull();
|
|
26
|
+
expect(container.querySelector('[data-slot="bar-cell"]')).toHaveAttribute(
|
|
27
|
+
"data-negative",
|
|
28
|
+
"true",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
it("reserves one value box per column, so a shorter number gets no longer bar", () => {
|
|
32
|
+
// Same reservation, different label lengths: the track (and therefore every
|
|
33
|
+
// bar drawn in it) is the same length on both rows.
|
|
34
|
+
const box = (label: string) => {
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<BarCell value={10} label={label} domain={[0, 40]} labelWidth={7} />,
|
|
37
|
+
);
|
|
38
|
+
return container.querySelector<HTMLElement>('[data-slot="bar-cell-value"]')?.style.width;
|
|
39
|
+
};
|
|
40
|
+
expect(box("+12.5 %")).toBe("7ch");
|
|
41
|
+
expect(box("-4.2 %")).toBe("7ch");
|
|
42
|
+
// A slim bar sits UNDER its value and spans the cell: nothing to reserve.
|
|
43
|
+
const { container } = render(
|
|
44
|
+
<BarCell value={10} label="-4.2 %" domain={[0, 40]} variant="slim" labelWidth={7} />,
|
|
45
|
+
);
|
|
46
|
+
expect(container.querySelector<HTMLElement>('[data-slot="bar-cell-value"]')?.style.width).toBe(
|
|
47
|
+
"",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
it("keeps the slim track off the column flex axis, so the mark has height", () => {
|
|
51
|
+
// `flex-1` is `flex: 1 1 0%`: in the slim variant the parent is a COLUMN,
|
|
52
|
+
// so that would resolve the track's HEIGHT to 0 and paint nothing.
|
|
53
|
+
const { container } = render(
|
|
54
|
+
<BarCell value={10} label="25 %" domain={[0, 40]} variant="slim" track />,
|
|
55
|
+
);
|
|
56
|
+
const slim = container.querySelector('[data-slot="bar-cell-track"]');
|
|
57
|
+
expect(slim).toHaveClass("h-1", "w-full", "shrink-0");
|
|
58
|
+
expect(slim).not.toHaveClass("flex-1");
|
|
59
|
+
const { container: regular } = render(
|
|
60
|
+
<BarCell value={10} label="25 %" domain={[0, 40]} track />,
|
|
61
|
+
);
|
|
62
|
+
const row = regular.querySelector('[data-slot="bar-cell-track"]');
|
|
63
|
+
expect(row).toHaveClass("h-3", "flex-1");
|
|
64
|
+
});
|
|
65
|
+
it("uses a category colour for positive bars; negative: false keeps it on negatives", () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<BarCell
|
|
68
|
+
value={-2}
|
|
69
|
+
label="-2"
|
|
70
|
+
domain={[-5, 5]}
|
|
71
|
+
fillColor="var(--chart-3)"
|
|
72
|
+
negativeColor={false}
|
|
73
|
+
/>,
|
|
74
|
+
);
|
|
75
|
+
expect(
|
|
76
|
+
container.querySelector<HTMLElement>('[data-slot="bar-cell-bar"]')?.style.backgroundColor,
|
|
77
|
+
).toBe("var(--chart-3)");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* b-4 — an in-cell bar is a mark whose LENGTH is the message, so it is a
|
|
83
|
+
* "graphical object required to understand the content" (WCAG 1.4.11, ≥ 3:1)
|
|
84
|
+
* and the house status-rung rule asks the same of a fill that IS the mark, in
|
|
85
|
+
* EVERY theme. Before this test the positive fill was `--chart-1`, a
|
|
86
|
+
* categorical token with no contrast guarantee that measures 1.42:1 on `--card`
|
|
87
|
+
* in `light` (a signed-off palette exemption — see `CHART_1411_EXEMPT` in
|
|
88
|
+
* packages/tokens/src/charts-contrast.test.ts). The token is not the bug; using
|
|
89
|
+
* a token that carries no guarantee for a mark that needs one is.
|
|
90
|
+
*
|
|
91
|
+
* Reads the shipped theme stylesheets rather than a copied number, so a retune
|
|
92
|
+
* that drops either fill below the bar fails here instead of shipping.
|
|
93
|
+
*/
|
|
94
|
+
describe("BarCell — the fills clear the 1.4.11 mark bar in every theme", () => {
|
|
95
|
+
const THEMES = ["light", "dark"] as const;
|
|
96
|
+
|
|
97
|
+
/** The shipped stylesheet for `theme`, found by walking up from the cwd. */
|
|
98
|
+
function themeCssPath(theme: (typeof THEMES)[number]): string {
|
|
99
|
+
let dir = process.cwd();
|
|
100
|
+
for (let up = 0; up < 6; up++) {
|
|
101
|
+
const candidate = join(dir, "packages/tokens/src/themes", `${theme}.css`);
|
|
102
|
+
if (existsSync(candidate)) return candidate;
|
|
103
|
+
dir = dirname(dir);
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`No theme stylesheet found for "${theme}" above ${process.cwd()}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function themeTokens(theme: (typeof THEMES)[number]): Map<string, string> {
|
|
109
|
+
const css = readFileSync(themeCssPath(theme), "utf8");
|
|
110
|
+
const map = new Map<string, string>();
|
|
111
|
+
for (const [, name, value] of css.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
|
|
112
|
+
map.set(name as string, (value as string).trim());
|
|
113
|
+
}
|
|
114
|
+
return map;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `var(--x)` → the token's own oklch value in `theme`, following aliases. */
|
|
118
|
+
function resolve(tokens: Map<string, string>, ref: string): string {
|
|
119
|
+
let value = ref;
|
|
120
|
+
for (let hop = 0; hop < 8 && value.startsWith("var("); hop++) {
|
|
121
|
+
value = tokens.get(value.slice(4, value.indexOf(")")).trim()) ?? "";
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** WCAG 2.x relative luminance of `#rrggbb`. */
|
|
127
|
+
function luminance(hex: string): number {
|
|
128
|
+
const channel = (i: number) => {
|
|
129
|
+
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
|
|
130
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
131
|
+
};
|
|
132
|
+
return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function contrast(a: string, b: string): number {
|
|
136
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
|
137
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it.each(THEMES)("%s: both bar fills are ≥ 3:1 on --card and on the bg-muted track", (theme) => {
|
|
141
|
+
const tokens = themeTokens(theme);
|
|
142
|
+
const card = oklchToHex(resolve(tokens, "var(--card)"));
|
|
143
|
+
const track = oklchToHex(resolve(tokens, "var(--muted)"));
|
|
144
|
+
expect(card, `${theme}: --card`).not.toBeNull();
|
|
145
|
+
expect(track, `${theme}: --muted`).not.toBeNull();
|
|
146
|
+
for (const fill of [BAR_CELL_POSITIVE_COLOR, BAR_CELL_NEGATIVE_COLOR]) {
|
|
147
|
+
const hex = oklchToHex(resolve(tokens, fill));
|
|
148
|
+
expect(hex, `${theme}: ${fill}`).not.toBeNull();
|
|
149
|
+
expect(contrast(hex as string, card as string), `${fill} on --card`).toBeGreaterThanOrEqual(
|
|
150
|
+
3,
|
|
151
|
+
);
|
|
152
|
+
expect(
|
|
153
|
+
contrast(hex as string, track as string),
|
|
154
|
+
`${fill} on the --muted track`,
|
|
155
|
+
).toBeGreaterThanOrEqual(3);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|