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/README.md +34 -0
- package/dist/index.d.ts +249 -54
- package/dist/index.js +347 -64
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- 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 +13 -2
- 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 +4 -3
- package/src/components/chart/types.ts +19 -2
- package/src/components/data-table/data-table.tsx +23 -1
- 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.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": {
|
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";
|
|
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={
|
|
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);
|
|
@@ -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
|
-
|
|
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 "
|
|
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))}
|