cortena-ui 1.2.0 → 1.3.1

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.1",
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": {
@@ -39,11 +39,11 @@
39
39
  "prepack": "pnpm build"
40
40
  },
41
41
  "peerDependencies": {
42
+ "@tanstack/react-query": "^5.0.0",
42
43
  "cortena-design": ">=3.2.0",
43
44
  "react": "^19.0.0",
44
45
  "react-dom": "^19.0.0",
45
- "zod": "^3.25.0 || ^4.0.0",
46
- "@tanstack/react-query": "^5.0.0"
46
+ "zod": "^3.25.0 || ^4.0.0"
47
47
  },
48
48
  "dependencies": {
49
49
  "@base-ui/react": "^1.8.0",
@@ -89,6 +89,7 @@
89
89
  "playwright": "^1.63.0",
90
90
  "react": "^19.1.0",
91
91
  "react-dom": "^19.1.0",
92
+ "rehype-raw": "^7.0.0",
92
93
  "tailwindcss": "^4.3.3",
93
94
  "tsdown": "^0.23.0",
94
95
  "typescript": "^5.9.0",
@@ -99,13 +100,5 @@
99
100
  },
100
101
  "publishConfig": {
101
102
  "access": "public"
102
- },
103
- "peerDependenciesMeta": {
104
- "zod": {
105
- "optional": true
106
- },
107
- "@tanstack/react-query": {
108
- "optional": true
109
- }
110
103
  }
111
104
  }
@@ -119,6 +119,15 @@ const buttonVariant = z
119
119
 
120
120
  const buttonSize = z.enum(["sm", "md", "lg"]).describe("Control size.");
121
121
 
122
+ /**
123
+ * Root-relative means one slash and then a path. `//evil.example/x` is
124
+ * protocol-relative — the browser resolves it against the page's scheme and
125
+ * goes off-origin — and `/\evil.example` is the same trick, because browsers
126
+ * treat a backslash after the leading slash as a second slash.
127
+ */
128
+ const rootRelative = (value: string) =>
129
+ value.startsWith("/") && value[1] !== "/" && value[1] !== "\\";
130
+
122
131
  /**
123
132
  * A URL an agent may point at.
124
133
  *
@@ -129,7 +138,7 @@ const buttonSize = z.enum(["sm", "md", "lg"]).describe("Control size.");
129
138
  const httpUrl = z
130
139
  .string()
131
140
  .refine(
132
- (value) => /^https?:\/\//i.test(value) || value.startsWith("/") || value.startsWith("#"),
141
+ (value) => /^https?:\/\//i.test(value) || rootRelative(value) || value.startsWith("#"),
133
142
  "Must be an http(s), root-relative or fragment URL.",
134
143
  )
135
144
  .describe("Destination URL (http, https, root-relative or fragment).");
@@ -137,7 +146,7 @@ const httpUrl = z
137
146
  const imageUrl = z
138
147
  .string()
139
148
  .refine(
140
- (value) => /^https?:\/\//i.test(value) || /^data:image\//i.test(value) || value.startsWith("/"),
149
+ (value) => /^https?:\/\//i.test(value) || /^data:image\//i.test(value) || rootRelative(value),
141
150
  "Must be an http(s), root-relative or data:image URL.",
142
151
  )
143
152
  .describe("Image URL (http, https, root-relative or data:image).");
@@ -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";
7
- import { NivoChart, type NivoType } from "./nivo-charts";
6
+ import { configFor, isEmptyData, isMatrix, isSeriesArray, toRows, toSlices } from "./data";
7
+ import { NivoChart, resetNivoChunks, 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":
@@ -79,16 +82,34 @@ function seriesKeys(type: ChartType, data: unknown, indexBy?: string, keys?: str
79
82
  }
80
83
  }
81
84
 
82
- class ChunkBoundary extends React.Component<
83
- { children: React.ReactNode; fallback: React.ReactNode },
84
- { failed: boolean }
85
- > {
86
- state = { failed: false };
85
+ interface ChunkBoundaryProps {
86
+ children: React.ReactNode;
87
+ /** Changing it clears a previous failure and renders the children again. */
88
+ resetKey: string;
89
+ fallback: (retry: () => void) => React.ReactNode;
90
+ }
91
+
92
+ /**
93
+ * Catches a Nivo chunk that failed to load. Recovering takes two things,
94
+ * because `React.lazy` memoises a rejection for the life of the page: fresh
95
+ * lazy components ({@link resetNivoChunks}) and a boundary that leaves its
96
+ * failed state. `resetKey` does the second on its own when the chart changes,
97
+ * and the fallback gets a `retry` that does both.
98
+ */
99
+ class ChunkBoundary extends React.Component<ChunkBoundaryProps, { failed: boolean; key: string }> {
100
+ state = { failed: false, key: this.props.resetKey };
87
101
  static getDerivedStateFromError() {
88
102
  return { failed: true };
89
103
  }
104
+ static getDerivedStateFromProps(props: ChunkBoundaryProps, state: { failed: boolean; key: string }) {
105
+ return props.resetKey === state.key ? null : { failed: false, key: props.resetKey };
106
+ }
107
+ retry = () => {
108
+ resetNivoChunks();
109
+ this.setState({ failed: false });
110
+ };
90
111
  render() {
91
- return this.state.failed ? this.props.fallback : this.props.children;
112
+ return this.state.failed ? this.props.fallback(this.retry) : this.props.children;
92
113
  }
93
114
  }
94
115
 
@@ -101,6 +122,14 @@ class ChunkBoundary extends React.Component<
101
122
  * Colours, fonts, axes, grid, tooltip, legend and the empty, loading and
102
123
  * error states all come from cortena-design tokens; a consumer passes data
103
124
  * and, optionally, a `config` of labels.
125
+ *
126
+ * `heatmap` takes either the Nivo series shape or a categorical
127
+ * {@link ChartMatrix} — `{ rows, columns, values }` — for the correlation and
128
+ * confusion matrices that are already in that form.
129
+ *
130
+ * `testMode` gives the drawing area a 600×300 floor so a chart still draws
131
+ * where the layout gives it no size; see {@link ChartContainerProps.testMode}
132
+ * and README.md, "Charts in tests".
104
133
  */
105
134
  function Chart<T extends ChartType>({
106
135
  type,
@@ -140,7 +169,22 @@ function Chart<T extends ChartType>({
140
169
  else if (empty) body = <ChartEmpty>{emptyMessage}</ChartEmpty>;
141
170
  else if (engine === "nivo") {
142
171
  body = (
143
- <ChunkBoundary fallback={<ChartError>Chart could not be loaded</ChartError>}>
172
+ <ChunkBoundary
173
+ resetKey={`${type}|${renderer}`}
174
+ fallback={(retry) => (
175
+ <ChartError>
176
+ Chart could not be loaded.{" "}
177
+ <button
178
+ type="button"
179
+ data-slot="chart-retry"
180
+ onClick={retry}
181
+ className="underline underline-offset-2"
182
+ >
183
+ Retry
184
+ </button>
185
+ </ChartError>
186
+ )}
187
+ >
144
188
  <React.Suspense fallback={<ChartLoading />}>
145
189
  <NivoChart
146
190
  type={type as NivoType}
@@ -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,