cortena-ui 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortena-ui",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Cortena UI — the shared React component library for cortenaweb and every Cortena extension. shadcn over Base UI, styled entirely from cortena-design tokens.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -8,7 +8,13 @@ import { cn } from "@/lib/cn";
8
8
  * Alert — an inline callout. Tinted variants use the `-soft` fill for the
9
9
  * background and the tone colour for the border and icon; the body text stays
10
10
  * on the foreground token so it reads in both themes.
11
+ *
12
+ * The error tone is `destructive`, the same spelling Button uses; `danger` is
13
+ * a deprecated alias of it and paints identically.
11
14
  */
15
+ const DESTRUCTIVE_ALERT =
16
+ "bg-[var(--ds-destructive-soft)] border-[var(--ds-destructive)]/35 [&_[data-slot=alert-icon]]:text-[color:var(--ds-destructive)]";
17
+
12
18
  const alertVariants = cva(
13
19
  [
14
20
  "flex gap-3 rounded-[var(--ds-radius-md)] border px-3.5 py-3",
@@ -23,8 +29,9 @@ const alertVariants = cva(
23
29
  success:
24
30
  "bg-[var(--ds-success-soft)] border-[var(--ds-success)]/35 [&_[data-slot=alert-icon]]:text-[color:var(--ds-success)]",
25
31
  warn: "bg-[var(--ds-warning-soft)] border-[var(--ds-warning)]/35 [&_[data-slot=alert-icon]]:text-[color:var(--ds-warning)]",
26
- danger:
27
- "bg-[var(--ds-destructive-soft)] border-[var(--ds-destructive)]/35 [&_[data-slot=alert-icon]]:text-[color:var(--ds-destructive)]",
32
+ destructive: DESTRUCTIVE_ALERT,
33
+ /** @deprecated Use `destructive`, the name Button and Badge use; this alias paints the same. */
34
+ danger: DESTRUCTIVE_ALERT,
28
35
  info: "bg-[var(--ds-info-soft)] border-[var(--ds-info)]/35 [&_[data-slot=alert-icon]]:text-[color:var(--ds-info)]",
29
36
  },
30
37
  },
@@ -6,9 +6,12 @@ import { cn } from "@/lib/cn";
6
6
 
7
7
  /**
8
8
  * Badge — the mono, uppercase status label. Each tone pairs a `-soft` fill
9
- * with its foreground token; `warn`, `danger` and `secondary` are aliases kept
10
- * for cortenaweb call sites.
9
+ * with its foreground token. The error tone is `destructive`, the same
10
+ * spelling Button and Alert use; `warn`, `danger` and `secondary` are
11
+ * deprecated aliases kept for cortenaweb call sites and paint identically.
11
12
  */
13
+ const DESTRUCTIVE_BADGE = "bg-[var(--ds-destructive-soft)] text-[color:var(--ds-destructive)]";
14
+
12
15
  const badgeVariants = cva(
13
16
  [
14
17
  "inline-flex items-center gap-1.5 px-1.5 py-0.5 whitespace-nowrap",
@@ -23,9 +26,11 @@ const badgeVariants = cva(
23
26
  default: "bg-[var(--ds-primary-soft)] text-[color:var(--ds-primary-soft-foreground)]",
24
27
  success: "bg-[var(--ds-success-soft)] text-[color:var(--ds-success)]",
25
28
  warning: "bg-[var(--ds-warning-soft)] text-[color:var(--ds-warning)]",
29
+ /** @deprecated Use `warning`; this alias paints the same. */
26
30
  warn: "bg-[var(--ds-warning-soft)] text-[color:var(--ds-warning)]",
27
- danger: "bg-[var(--ds-destructive-soft)] text-[color:var(--ds-destructive)]",
28
- destructive: "bg-[var(--ds-destructive-soft)] text-[color:var(--ds-destructive)]",
31
+ destructive: DESTRUCTIVE_BADGE,
32
+ /** @deprecated Use `destructive`, the name Button and Alert use; this alias paints the same. */
33
+ danger: DESTRUCTIVE_BADGE,
29
34
  info: "bg-[var(--ds-info-soft)] text-[color:var(--ds-info)]",
30
35
  neutral: "bg-[var(--ds-hover)] text-[color:var(--ds-muted-foreground)]",
31
36
  secondary: "bg-[var(--ds-hover)] text-[color:var(--ds-muted-foreground)]",
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { ChevronRight, MoreHorizontal } from "lucide-react";
4
+ import type * as React from "react";
5
+ import { cn } from "@/lib/cn";
6
+ import { renderWith, type RenderProp } from "@/lib/render";
7
+
8
+ /**
9
+ * Breadcrumb — the trail above a page title.
10
+ *
11
+ * A `<nav aria-label="Breadcrumb">` around an ordered list, which is what
12
+ * assistive technology expects: the list order is the hierarchy, so a screen
13
+ * reader announces "list, 4 items" and reads the path in order. Separators are
14
+ * decorative list items, not text inside the links, so a link's accessible
15
+ * name is the crumb and nothing else.
16
+ *
17
+ * The last crumb is `BreadcrumbPage`, not a link: it is the page you are on,
18
+ * so it carries `aria-current="page"` and is not focusable. Everything before
19
+ * it is a `BreadcrumbLink`.
20
+ *
21
+ * ```tsx
22
+ * <Breadcrumb>
23
+ * <BreadcrumbList>
24
+ * <BreadcrumbItem>
25
+ * <BreadcrumbLink href="/">Home</BreadcrumbLink>
26
+ * </BreadcrumbItem>
27
+ * <BreadcrumbSeparator />
28
+ * <BreadcrumbItem>
29
+ * <BreadcrumbEllipsis />
30
+ * </BreadcrumbItem>
31
+ * <BreadcrumbSeparator />
32
+ * <BreadcrumbItem>
33
+ * <BreadcrumbPage>Invoice 4021</BreadcrumbPage>
34
+ * </BreadcrumbItem>
35
+ * </BreadcrumbList>
36
+ * </Breadcrumb>
37
+ * ```
38
+ *
39
+ * **Router links.** `BreadcrumbLink` renders an `<a>` by default. Pass
40
+ * `render` with the router's link component and the class name, `href` and
41
+ * handlers are merged onto it, the same contract as `ButtonLink`:
42
+ * `render={<NextLink href="/projects" />}` or
43
+ * `render={<Link to="/projects" />}`. This is deliberately not Base UI's
44
+ * `render` — a breadcrumb is markup, not a Base UI primitive — but it takes
45
+ * the same shape so there is one thing to remember.
46
+ */
47
+ function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
48
+ return (
49
+ <nav
50
+ data-slot="breadcrumb"
51
+ aria-label="Breadcrumb"
52
+ className={cn("min-w-0", className)}
53
+ {...props}
54
+ />
55
+ );
56
+ }
57
+
58
+ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
59
+ return (
60
+ <ol
61
+ data-slot="breadcrumb-list"
62
+ className={cn(
63
+ "flex flex-wrap items-center gap-1.5 break-words p-0 sm:gap-2",
64
+ "list-none text-[length:var(--ds-text-caption-lg)]",
65
+ "leading-[var(--ds-text-caption-lg--line-height)] text-[color:var(--ds-muted-foreground)]",
66
+ className,
67
+ )}
68
+ {...props}
69
+ />
70
+ );
71
+ }
72
+
73
+ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
74
+ return (
75
+ <li
76
+ data-slot="breadcrumb-item"
77
+ className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ export interface BreadcrumbLinkProps extends React.ComponentProps<"a"> {
84
+ /** Replace the rendered `<a>` with a router link; props are merged onto it. */
85
+ render?: RenderProp;
86
+ }
87
+
88
+ function BreadcrumbLink({ className, render, children, ...props }: BreadcrumbLinkProps) {
89
+ const merged = {
90
+ ...props,
91
+ "data-slot": "breadcrumb-link",
92
+ className: cn(
93
+ "truncate rounded-[var(--ds-radius-sm)] text-[color:var(--ds-muted-foreground)] no-underline",
94
+ "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
95
+ "hover:text-[color:var(--ds-foreground)] hover:underline underline-offset-4",
96
+ "outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--ds-ring)]/40",
97
+ "[&_svg]:size-3.5 [&_svg]:shrink-0",
98
+ className,
99
+ ),
100
+ };
101
+ return renderWith(render, merged, children, "a");
102
+ }
103
+
104
+ /**
105
+ * The current page: the last crumb, which is not a link.
106
+ *
107
+ * A plain `<span aria-current="page">`, deliberately not `role="link"` with
108
+ * `aria-disabled` — a disabled link that cannot be focused is a fiction, and
109
+ * it makes `getByRole("link")` return a crumb that navigates nowhere. With a
110
+ * span, the roles in the trail are exactly the crumbs you can travel to.
111
+ */
112
+ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
113
+ return (
114
+ <span
115
+ data-slot="breadcrumb-page"
116
+ aria-current="page"
117
+ className={cn("truncate font-medium text-[color:var(--ds-foreground)]", className)}
118
+ {...props}
119
+ />
120
+ );
121
+ }
122
+
123
+ /** Decorative divider between two crumbs; a chevron unless children replace it. */
124
+ function BreadcrumbSeparator({ className, children, ...props }: React.ComponentProps<"li">) {
125
+ return (
126
+ <li
127
+ data-slot="breadcrumb-separator"
128
+ role="presentation"
129
+ aria-hidden="true"
130
+ className={cn(
131
+ "inline-flex shrink-0 items-center text-[color:var(--ds-text-tertiary)] [&_svg]:size-3.5",
132
+ className,
133
+ )}
134
+ {...props}
135
+ >
136
+ {children ?? <ChevronRight />}
137
+ </li>
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Stands in for the crumbs a long trail collapses; put it inside a
143
+ * BreadcrumbItem. The glyph is hidden and the label is not: an ellipsis that
144
+ * announces nothing leaves a screen-reader user with a gap in the path.
145
+ */
146
+ function BreadcrumbEllipsis({ className, children, ...props }: React.ComponentProps<"span">) {
147
+ return (
148
+ <span
149
+ data-slot="breadcrumb-ellipsis"
150
+ className={cn(
151
+ "inline-flex size-5 items-center justify-center text-[color:var(--ds-text-tertiary)]",
152
+ "[&_svg]:size-3.5",
153
+ className,
154
+ )}
155
+ {...props}
156
+ >
157
+ <MoreHorizontal aria-hidden />
158
+ <span className="sr-only">{children ?? "More levels"}</span>
159
+ </span>
160
+ );
161
+ }
162
+
163
+ export {
164
+ Breadcrumb,
165
+ BreadcrumbList,
166
+ BreadcrumbItem,
167
+ BreadcrumbLink,
168
+ BreadcrumbPage,
169
+ BreadcrumbSeparator,
170
+ BreadcrumbEllipsis,
171
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type * as React from "react";
4
4
  import { cn } from "@/lib/cn";
5
+ import { renderWith, type RenderProp } from "@/lib/render";
5
6
  import { buttonVariants, type ButtonProps } from "@/components/button";
6
7
 
7
8
  /**
@@ -22,7 +23,7 @@ export interface ButtonLinkProps
22
23
  Pick<ButtonProps, "variant" | "size"> {
23
24
  children?: React.ReactNode;
24
25
  /** Replace the rendered `<a>` with a router link; props are merged onto it. */
25
- render?: React.ReactElement<Record<string, unknown>>;
26
+ render?: RenderProp;
26
27
  disabled?: boolean;
27
28
  }
28
29
 
@@ -35,15 +36,7 @@ function ButtonLink({ className, variant, size, render, disabled, children, ...p
35
36
  "aria-disabled": disabled || undefined,
36
37
  tabIndex: disabled ? -1 : props.tabIndex,
37
38
  };
38
- if (render) {
39
- const Element = render.type as React.ElementType;
40
- return (
41
- <Element {...(render.props as Record<string, unknown>)} {...merged}>
42
- {children ?? (render.props as { children?: React.ReactNode }).children}
43
- </Element>
44
- );
45
- }
46
- return <a {...merged}>{children}</a>;
39
+ return renderWith(render, merged, children, "a");
47
40
  }
48
41
 
49
42
  export { ButtonLink };
@@ -2,28 +2,46 @@
2
2
 
3
3
  import type * as React from "react";
4
4
  import { cn } from "@/lib/cn";
5
+ import { renderWith, type RenderProp } from "@/lib/render";
5
6
 
6
7
  export interface CardProps extends React.ComponentProps<"div"> {
7
8
  /** Lift the border and surface on hover, for clickable cards. */
8
9
  hover?: boolean;
9
10
  /** Draw the border in the ring colour, for a selected or highlighted card. */
10
11
  accent?: boolean;
12
+ /**
13
+ * Replace the rendered `<div>` with another element, so a card can *be* the
14
+ * landmark rather than sit inside a wrapper that is one:
15
+ * `render={<aside />}` for a sidebar panel, `render={<section />}` (with an
16
+ * `aria-labelledby` pointing at its title) for a labelled region,
17
+ * `render={<article />}` for a self-contained item in a feed. Props set on
18
+ * the passed element come through; the card's own styling wins.
19
+ */
20
+ render?: RenderProp;
11
21
  }
12
22
 
13
- function Card({ className, hover, accent, ...props }: CardProps) {
14
- return (
15
- <div
16
- data-slot="card"
17
- data-accent={accent ? "" : undefined}
18
- className={cn(
23
+ /**
24
+ * Card — the bordered surface everything else sits on. `render` changes the
25
+ * element without changing the look, which is how a card becomes a landmark.
26
+ */
27
+ function Card({ className, hover, accent, render, children, ...props }: CardProps) {
28
+ return renderWith(
29
+ render,
30
+ {
31
+ // data-slot first: SectionCard (and any other composer) overrides it.
32
+ "data-slot": "card",
33
+ "data-accent": accent ? "" : undefined,
34
+ ...props,
35
+ className: cn(
19
36
  "flex flex-col rounded-[var(--ds-radius-lg)] border bg-[var(--ds-card)] text-[color:var(--ds-foreground)]",
20
37
  "transition-[border-color,background-color] duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
21
38
  accent ? "border-[var(--ds-ring)]" : "border-[var(--ds-border-subtle)]",
22
39
  hover && "hover:border-[var(--ds-border)] hover:bg-[var(--ds-popover)]",
23
40
  className,
24
- )}
25
- {...props}
26
- />
41
+ ),
42
+ },
43
+ children,
44
+ "div",
27
45
  );
28
46
  }
29
47
 
@@ -3,13 +3,14 @@
3
3
  import * as React from "react";
4
4
  import { cn } from "@/lib/cn";
5
5
  import { ChartContainer, ChartEmpty, ChartError, ChartLoading, type ChartContainerProps } from "./container";
6
- import { configFor, isEmptyData, isSeriesArray, toRows, toSlices } from "./data";
6
+ import { configFor, isEmptyData, isMatrix, isSeriesArray, toRows, toSlices } from "./data";
7
7
  import { NivoChart, type NivoType } from "./nivo-charts";
8
8
  import { RechartsChart, type RechartsType } from "./recharts-charts";
9
9
  import {
10
10
  chartEngine,
11
11
  type ChartConfig,
12
12
  type ChartDataFor,
13
+ type ChartMatrix,
13
14
  type ChartRenderer,
14
15
  type ChartRow,
15
16
  type ChartScale,
@@ -67,9 +68,11 @@ function seriesKeys(type: ChartType, data: unknown, indexBy?: string, keys?: str
67
68
  case "donut":
68
69
  case "funnel":
69
70
  return toSlices(data as ChartSlice[]).map((s) => s.id);
71
+ case "heatmap":
72
+ if (isMatrix(data)) return data.rows.map(String);
73
+ return isSeriesArray(data) ? data.map((s) => String(s.id)) : [];
70
74
  case "scatter":
71
75
  case "scatterplot":
72
- case "heatmap":
73
76
  return isSeriesArray(data) ? data.map((s) => String(s.id)) : [];
74
77
  case "treemap":
75
78
  case "sunburst":
@@ -101,6 +104,14 @@ class ChunkBoundary extends React.Component<
101
104
  * Colours, fonts, axes, grid, tooltip, legend and the empty, loading and
102
105
  * error states all come from cortena-design tokens; a consumer passes data
103
106
  * and, optionally, a `config` of labels.
107
+ *
108
+ * `heatmap` takes either the Nivo series shape or a categorical
109
+ * {@link ChartMatrix} — `{ rows, columns, values }` — for the correlation and
110
+ * confusion matrices that are already in that form.
111
+ *
112
+ * `testMode` gives the drawing area a 600×300 floor so a chart still draws
113
+ * where the layout gives it no size; see {@link ChartContainerProps.testMode}
114
+ * and README.md, "Charts in tests".
104
115
  */
105
116
  function Chart<T extends ChartType>({
106
117
  type,
@@ -57,9 +57,26 @@ export interface ChartContainerProps extends Omit<React.ComponentProps<"div">, "
57
57
  * `responsive={false}`.
58
58
  */
59
59
  responsive?: boolean;
60
+ /**
61
+ * Give the drawing area a floor of 600×300 so it draws where the layout
62
+ * gives it none.
63
+ *
64
+ * Both engines size themselves from the parent box. Under jsdom nothing has
65
+ * layout — every element measures 0×0 — so a page test that renders a screen
66
+ * containing a Chart gets an empty `<div>` and can assert nothing about it.
67
+ * `testMode` sets `min-width` / `min-height` on the container and hands the
68
+ * same floor to Recharts' ResponsiveContainer, so the chart draws at
69
+ * 600×300. It is a floor, not a fixed size: a parent that *does* give the
70
+ * chart 900px still gets 900px, so a screenshot test is unaffected.
71
+ */
72
+ testMode?: boolean;
60
73
  children: React.ReactNode;
61
74
  }
62
75
 
76
+ /** The box `testMode` falls back to when the layout gives the chart none. */
77
+ const TEST_WIDTH = 600;
78
+ const TEST_HEIGHT = 300;
79
+
63
80
  /**
64
81
  * ChartContainer — the sized, token-styled surface every chart draws in.
65
82
  *
@@ -72,6 +89,7 @@ function ChartContainer({
72
89
  config = {},
73
90
  height = 300,
74
91
  responsive = true,
92
+ testMode = false,
75
93
  className,
76
94
  style,
77
95
  children,
@@ -90,6 +108,7 @@ function ChartContainer({
90
108
  <div
91
109
  ref={setElement}
92
110
  data-slot="chart"
111
+ data-test-mode={testMode ? "" : undefined}
93
112
  className={cn(
94
113
  "relative w-full min-w-0 overflow-hidden text-[length:var(--ds-text-caption-sm)] text-[color:var(--ds-foreground)]",
95
114
  responsive ? "flex justify-center" : "block [&>div]:h-full [&>div]:w-full",
@@ -113,11 +132,23 @@ function ChartContainer({
113
132
  "[&_text]:font-[family-name:var(--ds-font-body)]",
114
133
  className,
115
134
  )}
116
- style={{ height, ...vars, ...style } as React.CSSProperties}
135
+ style={
136
+ {
137
+ height,
138
+ ...(testMode ? { minWidth: TEST_WIDTH, minHeight: TEST_HEIGHT } : null),
139
+ ...vars,
140
+ ...style,
141
+ } as React.CSSProperties
142
+ }
117
143
  {...props}
118
144
  >
119
145
  {responsive ? (
120
- <ResponsiveContainer width="100%" height="100%">
146
+ <ResponsiveContainer
147
+ width="100%"
148
+ height="100%"
149
+ minWidth={testMode ? TEST_WIDTH : undefined}
150
+ minHeight={testMode ? TEST_HEIGHT : undefined}
151
+ >
121
152
  {children as React.ReactElement}
122
153
  </ResponsiveContainer>
123
154
  ) : (
@@ -1,4 +1,4 @@
1
- import type { ChartConfig, ChartRow, ChartSeries, ChartSlice, ChartTree } from "./types";
1
+ import type { ChartConfig, ChartMatrix, ChartRow, ChartSeries, ChartSlice, ChartTree } from "./types";
2
2
 
3
3
  /** True for the `{ id, data: [{ x, y }] }[]` series shape. */
4
4
  export function isSeriesArray(data: unknown): data is ChartSeries[] {
@@ -9,6 +9,29 @@ export function isSeriesArray(data: unknown): data is ChartSeries[] {
9
9
  );
10
10
  }
11
11
 
12
+ /** True for the `{ rows, columns, values }` categorical matrix shape. */
13
+ export function isMatrix(data: unknown): data is ChartMatrix {
14
+ if (!data || typeof data !== "object" || Array.isArray(data)) return false;
15
+ const m = data as ChartMatrix;
16
+ return Array.isArray(m.rows) && Array.isArray(m.columns) && Array.isArray(m.values);
17
+ }
18
+
19
+ /**
20
+ * Pivot a categorical matrix onto the Nivo heatmap shape. Every row gets a
21
+ * point for every column so the grid stays rectangular; a missing or
22
+ * non-finite cell becomes `null`, which Nivo paints with `emptyColor` rather
23
+ * than as a zero.
24
+ */
25
+ export function matrixToSeries(matrix: ChartMatrix): ChartSeries[] {
26
+ return matrix.rows.map((row, r) => ({
27
+ id: row,
28
+ data: matrix.columns.map((column, c) => {
29
+ const value = matrix.values[r]?.[c];
30
+ return { x: column, y: typeof value === "number" && Number.isFinite(value) ? value : null };
31
+ }),
32
+ }));
33
+ }
34
+
12
35
  /**
13
36
  * Infer the category key and the series keys of a row array the way the
14
37
  * webchat renderer did: the first string field is the index, every numeric
@@ -88,6 +111,7 @@ export function treeIdentity(tree: ChartTree): "id" | "name" {
88
111
  /** True when there is nothing to draw. */
89
112
  export function isEmptyData(data: unknown): boolean {
90
113
  if (data == null) return true;
114
+ if (isMatrix(data)) return data.rows.length === 0 || data.columns.length === 0;
91
115
  if (Array.isArray(data)) {
92
116
  if (data.length === 0) return true;
93
117
  if (isSeriesArray(data)) return data.every((s) => s.data.length === 0);
@@ -41,6 +41,7 @@ export type {
41
41
  ChartDataFor,
42
42
  ChartDay,
43
43
  ChartEngine,
44
+ ChartMatrix,
44
45
  ChartPoint,
45
46
  ChartRenderer,
46
47
  ChartRow,
@@ -2,10 +2,10 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { useChart } from "./container";
5
- import { treeIdentity } from "./data";
5
+ import { isMatrix, matrixToSeries, treeIdentity } from "./data";
6
6
  import { useNivoTheme } from "./nivo-theme";
7
7
  import { chartScales, contrastLabel, resolveVizSeries, useThemeVersion } from "./tokens";
8
- import type { ChartConfig, ChartDay, ChartRenderer, ChartScale, ChartSeries, ChartTree, ChartType } from "./types";
8
+ import type { ChartConfig, ChartDay, ChartMatrix, ChartRenderer, ChartScale, ChartSeries, ChartTree, ChartType } from "./types";
9
9
 
10
10
  export type NivoType = Extract<ChartType, "heatmap" | "calendar" | "treemap" | "sunburst">;
11
11
 
@@ -94,7 +94,8 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
94
94
  switch (type) {
95
95
  case "heatmap": {
96
96
  const Component = canvas ? HeatMapCanvas : HeatMapSvg;
97
- const rows = data as ChartSeries[];
97
+ // Either shape lands on Nivo's `[{ id, data: [{ x, y }] }]`.
98
+ const rows = isMatrix(data) ? matrixToSeries(data as ChartMatrix) : (data as ChartSeries[]);
98
99
  // Labels only when cells are big enough to carry them.
99
100
  const cells = rows.reduce((n, r) => n + r.data.length, 0);
100
101
  const colorConfig =
@@ -21,7 +21,10 @@ import type * as React from "react";
21
21
  * (the Nivo line shape the webchat emits). `ChartRow[]` is also accepted.
22
22
  * - **pie**, **donut**, **funnel**: `ChartSlice[]` — `[{ id, label?, value }]`.
23
23
  * - **scatter** (alias `scatterplot`): `ChartSeries[]` — one series per group.
24
- * - **heatmap**: `ChartSeries[]` — Nivo heatmap shape, `[{ id: row, data: [{ x: column, y: value }] }]`.
24
+ * - **heatmap**: `ChartSeries[]` — Nivo heatmap shape, `[{ id: row, data: [{ x: column, y: value }] }]` —
25
+ * or `ChartMatrix`, the categorical form a correlation / confusion matrix
26
+ * already has: `{ rows, columns, values }` with `values[r][c]` at
27
+ * `rows[r] × columns[c]`.
25
28
  * - **calendar**: `ChartDay[]` — `[{ day: "2026-01-31", value }]`; `from`/`to`
26
29
  * default to the data's range.
27
30
  * - **treemap**, **sunburst**: `ChartTree` — Nivo tree, `{ id, children?: [...], value? }`
@@ -68,6 +71,18 @@ export interface ChartSlice {
68
71
  value: number;
69
72
  }
70
73
 
74
+ /**
75
+ * A categorical matrix: `values[r][c]` is the cell at `rows[r]` × `columns[c]`.
76
+ * The shape a correlation matrix, a confusion matrix or a day × hour grid is
77
+ * already in, so it needs no pivot at the call site. Short rows are padded
78
+ * with `null` (drawn as the empty colour) rather than treated as zero.
79
+ */
80
+ export interface ChartMatrix {
81
+ rows: string[];
82
+ columns: string[];
83
+ values: Array<Array<number | null | undefined>>;
84
+ }
85
+
71
86
  /** One day of a calendar heatmap. */
72
87
  export interface ChartDay {
73
88
  day: string;
@@ -90,7 +105,9 @@ export type ChartDataFor<T extends ChartType> = T extends "bar" | "radar"
90
105
  ? ChartSeries[] | ChartRow[]
91
106
  : T extends "pie" | "donut" | "funnel"
92
107
  ? ChartSlice[]
93
- : T extends "scatter" | "scatterplot" | "heatmap"
108
+ : T extends "heatmap"
109
+ ? ChartSeries[] | ChartMatrix
110
+ : T extends "scatter" | "scatterplot"
94
111
  ? ChartSeries[]
95
112
  : T extends "calendar"
96
113
  ? ChartDay[]
@@ -78,6 +78,16 @@ export interface DataTableViewProps<Row extends RowData> {
78
78
  /** Error to show above the table, in addition to the server source's. */
79
79
  error?: string | Error | null;
80
80
  onRowClick?: (row: Row, event: React.MouseEvent<HTMLTableRowElement>) => void;
81
+ /**
82
+ * The row a detail pane is currently showing, matched against the row id
83
+ * (`getRowId`, the row index when that is not set). It gets `data-active`
84
+ * and the `--ds-primary-soft` tint, independent of checkbox selection — a
85
+ * master/detail list highlights one row without selecting it, and a table
86
+ * with both can show a selected set and one open row at once.
87
+ */
88
+ activeRowId?: string | null;
89
+ /** Extra classes per row, e.g. to dim rows the app considers stale. */
90
+ rowClassName?: (row: Row) => string | undefined;
81
91
  }
82
92
 
83
93
  export type DataTableProps<Row extends RowData> = DataTableViewProps<Row> &
@@ -112,6 +122,8 @@ function DataTableOwned<Row extends RowData>(props: DataTableViewProps<Row> & Us
112
122
  loading,
113
123
  error,
114
124
  onRowClick,
125
+ activeRowId,
126
+ rowClassName,
115
127
  ...options
116
128
  } = props;
117
129
  const instance = useDataTable<Row>({
@@ -140,6 +152,8 @@ function DataTableOwned<Row extends RowData>(props: DataTableViewProps<Row> & Us
140
152
  loading={loading}
141
153
  error={error}
142
154
  onRowClick={onRowClick}
155
+ activeRowId={activeRowId}
156
+ rowClassName={rowClassName}
143
157
  />
144
158
  );
145
159
  }
@@ -259,6 +273,8 @@ function DataTableView<Row extends RowData>({
259
273
  loading,
260
274
  error,
261
275
  onRowClick,
276
+ activeRowId,
277
+ rowClassName,
262
278
  }: DataTableViewProps<Row> & { instance: DataTableInstance<Row> }) {
263
279
  const { table, paginationMode } = instance;
264
280
  const rows = table.getRowModel().rows;
@@ -446,6 +462,7 @@ function DataTableView<Row extends RowData>({
446
462
  const endStart = cells.findIndex((cell) => cell.column.getIsPinned() === "end");
447
463
  const splitAt = endStart === -1 ? cells.length : endStart;
448
464
  const selected = row.getIsSelected();
465
+ const active = activeRowId != null && row.id === activeRowId;
449
466
  const expanded = renderSubComponent && row.getIsExpanded();
450
467
 
451
468
  const renderCell = (cell: DataTableCell<Row, any>, c: number) => {
@@ -481,7 +498,7 @@ function DataTableView<Row extends RowData>({
481
498
  // Pinned cells cover what scrolls under them, so their background is
482
499
  // opaque card with the translucent hover / selected tint layered on.
483
500
  pin.pinned &&
484
- "z-[1] bg-[var(--ds-card)] group-hover/row:bg-[linear-gradient(var(--ds-hover),var(--ds-hover))] group-data-[selected]/row:bg-[linear-gradient(var(--ds-primary-soft),var(--ds-primary-soft))]",
501
+ "z-[1] bg-[var(--ds-card)] group-hover/row:bg-[linear-gradient(var(--ds-hover),var(--ds-hover))] group-data-[selected]/row:bg-[linear-gradient(var(--ds-primary-soft),var(--ds-primary-soft))] group-data-[active]/row:bg-[linear-gradient(var(--ds-primary-soft),var(--ds-primary-soft))]",
485
502
  pin.pinned === "start" && pin.edge && "shadow-[inset_-1px_0_0_var(--ds-border)]",
486
503
  pin.pinned === "end" && pin.edge && "shadow-[inset_1px_0_0_var(--ds-border)]",
487
504
  !isEditing && "truncate",
@@ -510,6 +527,7 @@ function DataTableView<Row extends RowData>({
510
527
  data-slot="data-table-row"
511
528
  data-index={virtualIndex}
512
529
  data-selected={selected ? "" : undefined}
530
+ data-active={active ? "" : undefined}
513
531
  data-expanded={expanded ? "" : undefined}
514
532
  aria-rowindex={index + 2}
515
533
  aria-selected={table.options.enableRowSelection ? selected : undefined}
@@ -518,7 +536,11 @@ function DataTableView<Row extends RowData>({
518
536
  className={cn(
519
537
  "group/row border-b border-[var(--ds-border-subtle)] transition-colors duration-[var(--ds-duration-fast)]",
520
538
  "hover:bg-[var(--ds-hover)] data-[selected]:bg-[var(--ds-primary-soft)]",
539
+ // Independent of selection: a detail pane highlights one row
540
+ // without ticking its checkbox.
541
+ "data-[active]:bg-[var(--ds-primary-soft)]",
521
542
  onRowClick && "cursor-pointer",
543
+ rowClassName?.(row.original),
522
544
  )}
523
545
  >
524
546
  {cells.slice(0, splitAt).map((cell, c) => renderCell(cell, c))}