cortena-ui 1.3.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/dist/index.d.ts +6 -3
- package/dist/index.js +94 -22
- package/dist/index.js.map +1 -1
- package/package.json +4 -11
- package/src/a2ui/catalogue.ts +11 -2
- package/src/components/chart/chart.tsx +41 -8
- package/src/components/chart/nivo-charts.tsx +41 -13
- package/src/components/data-table/export.ts +18 -2
- package/src/components/data-table/use-server-source.ts +17 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortena-ui",
|
|
3
|
-
"version": "1.3.
|
|
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).");
|
|
@@ -4,7 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import { cn } from "@/lib/cn";
|
|
5
5
|
import { ChartContainer, ChartEmpty, ChartError, ChartLoading, type ChartContainerProps } from "./container";
|
|
6
6
|
import { configFor, isEmptyData, isMatrix, isSeriesArray, toRows, toSlices } from "./data";
|
|
7
|
-
import { NivoChart, type NivoType } from "./nivo-charts";
|
|
7
|
+
import { NivoChart, resetNivoChunks, type NivoType } from "./nivo-charts";
|
|
8
8
|
import { RechartsChart, type RechartsType } from "./recharts-charts";
|
|
9
9
|
import {
|
|
10
10
|
chartEngine,
|
|
@@ -82,16 +82,34 @@ function seriesKeys(type: ChartType, data: unknown, indexBy?: string, keys?: str
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 };
|
|
90
101
|
static getDerivedStateFromError() {
|
|
91
102
|
return { failed: true };
|
|
92
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
|
+
};
|
|
93
111
|
render() {
|
|
94
|
-
return this.state.failed ? this.props.fallback : this.props.children;
|
|
112
|
+
return this.state.failed ? this.props.fallback(this.retry) : this.props.children;
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
115
|
|
|
@@ -151,7 +169,22 @@ function Chart<T extends ChartType>({
|
|
|
151
169
|
else if (empty) body = <ChartEmpty>{emptyMessage}</ChartEmpty>;
|
|
152
170
|
else if (engine === "nivo") {
|
|
153
171
|
body = (
|
|
154
|
-
<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
|
+
>
|
|
155
188
|
<React.Suspense fallback={<ChartLoading />}>
|
|
156
189
|
<NivoChart
|
|
157
190
|
type={type as NivoType}
|
|
@@ -18,16 +18,42 @@ export type NivoType = Extract<ChartType, "heatmap" | "calendar" | "treemap" | "
|
|
|
18
18
|
*/
|
|
19
19
|
type AnyComponent = React.ComponentType<Record<string, unknown>>;
|
|
20
20
|
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const loaders = {
|
|
22
|
+
"heatmap-svg": async () => (await import("@nivo/heatmap")).ResponsiveHeatMap as AnyComponent,
|
|
23
|
+
"heatmap-canvas": async () => (await import("@nivo/heatmap")).ResponsiveHeatMapCanvas as AnyComponent,
|
|
24
|
+
"calendar-svg": async () => (await import("@nivo/calendar")).ResponsiveCalendar as unknown as AnyComponent,
|
|
25
|
+
"calendar-canvas": async () =>
|
|
26
|
+
(await import("@nivo/calendar")).ResponsiveCalendarCanvas as unknown as AnyComponent,
|
|
27
|
+
"treemap-svg": async () => (await import("@nivo/treemap")).ResponsiveTreeMap as AnyComponent,
|
|
28
|
+
"treemap-canvas": async () => (await import("@nivo/treemap")).ResponsiveTreeMapCanvas as AnyComponent,
|
|
29
|
+
"sunburst-svg": async () => (await import("@nivo/sunburst")).ResponsiveSunburst as AnyComponent,
|
|
30
|
+
} satisfies Record<string, () => Promise<AnyComponent>>;
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
type ChunkName = keyof typeof loaders;
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* `React.lazy` memoises the outcome of its loader, a rejection included: a
|
|
36
|
+
* chunk that fails once — an offline moment, a deploy that moved the file —
|
|
37
|
+
* throws the same error for the life of the page, and no re-render retries it.
|
|
38
|
+
* The lazy components are therefore cached here rather than frozen at module
|
|
39
|
+
* scope, and `resetNivoChunks` drops the cache so the next render builds fresh
|
|
40
|
+
* ones and asks for the chunk again. `Chart`'s ChunkBoundary calls it.
|
|
41
|
+
*/
|
|
42
|
+
let chunks = new Map<ChunkName, AnyComponent>();
|
|
43
|
+
|
|
44
|
+
function chunk(name: ChunkName): AnyComponent {
|
|
45
|
+
let component = chunks.get(name);
|
|
46
|
+
if (!component) {
|
|
47
|
+
component = React.lazy(async () => ({ default: await loaders[name]() })) as unknown as AnyComponent;
|
|
48
|
+
chunks.set(name, component);
|
|
49
|
+
}
|
|
50
|
+
return component;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Forget every loaded-or-failed Nivo chunk, so the next render loads it again. */
|
|
54
|
+
export function resetNivoChunks(): void {
|
|
55
|
+
chunks = new Map();
|
|
56
|
+
}
|
|
31
57
|
|
|
32
58
|
export interface NivoRenderProps {
|
|
33
59
|
type: NivoType;
|
|
@@ -93,7 +119,7 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
93
119
|
|
|
94
120
|
switch (type) {
|
|
95
121
|
case "heatmap": {
|
|
96
|
-
const Component = canvas ?
|
|
122
|
+
const Component = chunk(canvas ? "heatmap-canvas" : "heatmap-svg");
|
|
97
123
|
// Either shape lands on Nivo's `[{ id, data: [{ x, y }] }]`.
|
|
98
124
|
const rows = isMatrix(data) ? matrixToSeries(data as ChartMatrix) : (data as ChartSeries[]);
|
|
99
125
|
// Labels only when cells are big enough to carry them.
|
|
@@ -139,7 +165,7 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
case "calendar": {
|
|
142
|
-
const Component = canvas ?
|
|
168
|
+
const Component = chunk(canvas ? "calendar-canvas" : "calendar-svg");
|
|
143
169
|
const days = data as ChartDay[];
|
|
144
170
|
const range = calendarRange(days, from, to);
|
|
145
171
|
return (
|
|
@@ -177,7 +203,7 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
177
203
|
}
|
|
178
204
|
|
|
179
205
|
case "treemap": {
|
|
180
|
-
const Component = canvas ?
|
|
206
|
+
const Component = chunk(canvas ? "treemap-canvas" : "treemap-svg");
|
|
181
207
|
const tree = data as ChartTree;
|
|
182
208
|
return (
|
|
183
209
|
<Component
|
|
@@ -202,9 +228,11 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
202
228
|
}
|
|
203
229
|
|
|
204
230
|
case "sunburst": {
|
|
231
|
+
// Sunburst has no canvas renderer; SVG either way.
|
|
232
|
+
const Component = chunk("sunburst-svg");
|
|
205
233
|
const tree = data as ChartTree;
|
|
206
234
|
return (
|
|
207
|
-
<
|
|
235
|
+
<Component
|
|
208
236
|
data={tree}
|
|
209
237
|
id={treeIdentity(tree)}
|
|
210
238
|
value="value"
|
|
@@ -54,13 +54,29 @@ export function toSheet<Row extends RowData>(
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Text a spreadsheet would evaluate rather than display. Excel, Sheets and
|
|
59
|
+
* LibreOffice all treat a leading `=`, `+`, `-` or `@` as the start of a
|
|
60
|
+
* formula, and a leading tab or CR as whitespace they strip before looking
|
|
61
|
+
* again — so `\t=cmd|'/c calc'!A1` is a formula too. Only strings are checked:
|
|
62
|
+
* a number, boolean or Date cell cannot carry one, and prefixing `-5` would
|
|
63
|
+
* turn a number column into text.
|
|
64
|
+
*/
|
|
65
|
+
const FORMULA_START = /^[=+\-@\t\r]/;
|
|
66
|
+
|
|
57
67
|
function csvField(value: ExportCell): string {
|
|
58
68
|
if (value == null) return "";
|
|
59
|
-
|
|
69
|
+
let text = value instanceof Date ? value.toISOString() : String(value);
|
|
70
|
+
// Prefix before quoting: the apostrophe is what makes the spreadsheet read
|
|
71
|
+
// the cell as text, and it has to sit inside the quotes to survive the parse.
|
|
72
|
+
if (typeof value === "string" && FORMULA_START.test(text)) text = `'${text}`;
|
|
60
73
|
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* RFC 4180 text: comma separated, CRLF lines, quotes doubled. A cell that a
|
|
78
|
+
* spreadsheet would read as a formula is prefixed with `'` so it stays text.
|
|
79
|
+
*/
|
|
64
80
|
export function toCsv<Row extends RowData>(
|
|
65
81
|
table: DataTableTable<Row>,
|
|
66
82
|
rows?: DataTableRow<Row>[],
|
|
@@ -168,11 +168,23 @@ function useLocalStrategy<Row>(
|
|
|
168
168
|
: !(state.scopeKey === scopeKey && state.page === query.page && state.pages.length > 0);
|
|
169
169
|
const requestKey = `${scopeKey}|${targetPage}|${tick}`;
|
|
170
170
|
|
|
171
|
+
// The consumer's `fetch` is usually an inline arrow — `fetch: q => api.list(q)`
|
|
172
|
+
// is the documented call — so it is a new function on every parent render.
|
|
173
|
+
// Depending on it would abort and refire the request each time; the request's
|
|
174
|
+
// identity is `requestKey`, so the latest function is read from a ref instead.
|
|
175
|
+
const fetchRef = React.useRef(fetch);
|
|
171
176
|
React.useEffect(() => {
|
|
172
|
-
|
|
177
|
+
fetchRef.current = fetch;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const hasFetch = Boolean(fetch);
|
|
181
|
+
|
|
182
|
+
React.useEffect(() => {
|
|
183
|
+
const run = fetchRef.current;
|
|
184
|
+
if (!run || !needs) return;
|
|
173
185
|
const controller = new AbortController();
|
|
174
186
|
setState((s) => ({ ...s, isFetching: true }));
|
|
175
|
-
|
|
187
|
+
run({ ...scope, page: targetPage }, controller.signal).then(
|
|
176
188
|
(result) => {
|
|
177
189
|
if (controller.signal.aborted) return;
|
|
178
190
|
setState((s) => ({
|
|
@@ -189,9 +201,10 @@ function useLocalStrategy<Row>(
|
|
|
189
201
|
},
|
|
190
202
|
);
|
|
191
203
|
return () => controller.abort();
|
|
192
|
-
// `requestKey` is the identity of the request; `scope` and `targetPage` derive
|
|
204
|
+
// `requestKey` is the identity of the request; `scope` and `targetPage` derive
|
|
205
|
+
// from it. `fetch` itself is held in a ref, so only its presence is a dep.
|
|
193
206
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
194
|
-
}, [
|
|
207
|
+
}, [hasFetch, requestKey, needs]);
|
|
195
208
|
|
|
196
209
|
// Like keepPreviousData: the last result stays on screen while the next loads.
|
|
197
210
|
const rows = React.useMemo(
|