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/README.md +34 -0
- package/dist/index.d.ts +253 -55
- package/dist/index.js +441 -86
- package/dist/index.js.map +1 -1
- package/package.json +4 -11
- package/src/a2ui/catalogue.ts +11 -2
- package/src/components/alert.tsx +9 -2
- package/src/components/badge.tsx +9 -4
- package/src/components/breadcrumb.tsx +171 -0
- package/src/components/button-link.tsx +3 -10
- package/src/components/card.tsx +27 -9
- package/src/components/chart/chart.tsx +54 -10
- package/src/components/chart/container.tsx +33 -2
- package/src/components/chart/data.ts +25 -1
- package/src/components/chart/index.tsx +1 -0
- package/src/components/chart/nivo-charts.tsx +45 -16
- package/src/components/chart/types.ts +19 -2
- package/src/components/data-table/data-table.tsx +23 -1
- package/src/components/data-table/export.ts +18 -2
- package/src/components/data-table/use-server-source.ts +17 -4
- package/src/components/dropzone.tsx +35 -1
- package/src/components/section-card.tsx +4 -0
- package/src/components/select.tsx +30 -1
- package/src/components/sheet.tsx +40 -4
- package/src/components/sortable-list.tsx +77 -9
- package/src/components/spinner.tsx +21 -4
- package/src/components/status-dot.tsx +14 -1
- package/src/index.ts +5 -3
- package/src/lib/render.tsx +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortena-ui",
|
|
3
|
-
"version": "1.
|
|
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
|
}
|
package/src/a2ui/catalogue.ts
CHANGED
|
@@ -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
|
|
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
|
|
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).");
|
package/src/components/alert.tsx
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
},
|
package/src/components/badge.tsx
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
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?:
|
|
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
|
-
|
|
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 };
|
package/src/components/card.tsx
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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={
|
|
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
|
|
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);
|