@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,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="" />,
|
|
30
|
+
);
|
|
31
|
+
expect(container.querySelector("img")).toBeNull();
|
|
32
|
+
expect(container).toHaveTextContent("flag");
|
|
33
|
+
rerender(<MarkdownCell text="" 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` — ``. 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 `` 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
|
+
});
|