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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortena-ui",
3
- "version": "1.3.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).");
@@ -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
- class ChunkBoundary extends React.Component<
86
- { children: React.ReactNode; fallback: React.ReactNode },
87
- { failed: boolean }
88
- > {
89
- 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 };
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 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
+ >
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 lazy = (load: () => Promise<AnyComponent>) =>
22
- React.lazy(async () => ({ default: await load() }));
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
- const HeatMapSvg = lazy(async () => (await import("@nivo/heatmap")).ResponsiveHeatMap as AnyComponent);
25
- const HeatMapCanvas = lazy(async () => (await import("@nivo/heatmap")).ResponsiveHeatMapCanvas as AnyComponent);
26
- const CalendarSvg = lazy(async () => (await import("@nivo/calendar")).ResponsiveCalendar as unknown as AnyComponent);
27
- const CalendarCanvas = lazy(async () => (await import("@nivo/calendar")).ResponsiveCalendarCanvas as unknown as AnyComponent);
28
- const TreeMapSvg = lazy(async () => (await import("@nivo/treemap")).ResponsiveTreeMap as AnyComponent);
29
- const TreeMapCanvas = lazy(async () => (await import("@nivo/treemap")).ResponsiveTreeMapCanvas as AnyComponent);
30
- const SunburstSvg = lazy(async () => (await import("@nivo/sunburst")).ResponsiveSunburst as AnyComponent);
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 ? HeatMapCanvas : HeatMapSvg;
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 ? CalendarCanvas : CalendarSvg;
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 ? TreeMapCanvas : TreeMapSvg;
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
- <SunburstSvg
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
- const text = value instanceof Date ? value.toISOString() : String(value);
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
- /** RFC 4180 text: comma separated, CRLF lines, quotes doubled. */
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
- if (!fetch || !needs) return;
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
- fetch({ ...scope, page: targetPage }, controller.signal).then(
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 from it.
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
- }, [fetch, requestKey, needs]);
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(