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
|
@@ -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
|
|
|
@@ -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,8 +119,9 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
93
119
|
|
|
94
120
|
switch (type) {
|
|
95
121
|
case "heatmap": {
|
|
96
|
-
const Component = canvas ?
|
|
97
|
-
|
|
122
|
+
const Component = chunk(canvas ? "heatmap-canvas" : "heatmap-svg");
|
|
123
|
+
// Either shape lands on Nivo's `[{ id, data: [{ x, y }] }]`.
|
|
124
|
+
const rows = isMatrix(data) ? matrixToSeries(data as ChartMatrix) : (data as ChartSeries[]);
|
|
98
125
|
// Labels only when cells are big enough to carry them.
|
|
99
126
|
const cells = rows.reduce((n, r) => n + r.data.length, 0);
|
|
100
127
|
const colorConfig =
|
|
@@ -138,7 +165,7 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
138
165
|
}
|
|
139
166
|
|
|
140
167
|
case "calendar": {
|
|
141
|
-
const Component = canvas ?
|
|
168
|
+
const Component = chunk(canvas ? "calendar-canvas" : "calendar-svg");
|
|
142
169
|
const days = data as ChartDay[];
|
|
143
170
|
const range = calendarRange(days, from, to);
|
|
144
171
|
return (
|
|
@@ -176,7 +203,7 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
176
203
|
}
|
|
177
204
|
|
|
178
205
|
case "treemap": {
|
|
179
|
-
const Component = canvas ?
|
|
206
|
+
const Component = chunk(canvas ? "treemap-canvas" : "treemap-svg");
|
|
180
207
|
const tree = data as ChartTree;
|
|
181
208
|
return (
|
|
182
209
|
<Component
|
|
@@ -201,9 +228,11 @@ export function NivoChart({ type, data, config, renderer, scale, legend, valueFo
|
|
|
201
228
|
}
|
|
202
229
|
|
|
203
230
|
case "sunburst": {
|
|
231
|
+
// Sunburst has no canvas renderer; SVG either way.
|
|
232
|
+
const Component = chunk("sunburst-svg");
|
|
204
233
|
const tree = data as ChartTree;
|
|
205
234
|
return (
|
|
206
|
-
<
|
|
235
|
+
<Component
|
|
207
236
|
data={tree}
|
|
208
237
|
id={treeIdentity(tree)}
|
|
209
238
|
value="value"
|
|
@@ -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))}
|
|
@@ -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(
|
|
@@ -21,6 +21,30 @@ import { cn } from "@/lib/cn";
|
|
|
21
21
|
* `onFiles` receives only the accepted files; rejections go to `onReject`.
|
|
22
22
|
* `FileList` and `formatFileSize` are separate parts for listing what was
|
|
23
23
|
* picked, so a form can keep the zone and the list in different places.
|
|
24
|
+
*
|
|
25
|
+
* ## Wrapping a whole page as a drop target
|
|
26
|
+
*
|
|
27
|
+
* `noClick` and `noKeyboard` turn off the two activators that only make sense
|
|
28
|
+
* on a dashed box: clicking anywhere inside opens the file dialog, and Enter /
|
|
29
|
+
* Space on the focused root does the same. A page-sized zone wraps content
|
|
30
|
+
* that has its own buttons and inputs, so both have to go — otherwise every
|
|
31
|
+
* click in the page opens a file picker.
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <Dropzone noClick noKeyboard onFiles={upload} className="min-h-dvh border-0 bg-transparent p-0">
|
|
35
|
+
* {({ isDragActive, open }) => (
|
|
36
|
+
* <>
|
|
37
|
+
* <PageContent />
|
|
38
|
+
* <Button onClick={open}>Browse…</Button>
|
|
39
|
+
* {isDragActive ? <DropHint /> : null}
|
|
40
|
+
* </>
|
|
41
|
+
* )}
|
|
42
|
+
* </Dropzone>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* The render-prop form gets react-dropzone's `open()`, which is the way back
|
|
46
|
+
* to the file dialog from a deliberate control. With `noClick` the root also
|
|
47
|
+
* drops its `cursor-pointer`, since it is no longer clickable.
|
|
24
48
|
*/
|
|
25
49
|
|
|
26
50
|
const UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
|
|
@@ -71,6 +95,10 @@ export interface DropzoneProps extends Omit<
|
|
|
71
95
|
/** Most files accepted per drop; `0` means unlimited. */
|
|
72
96
|
maxFiles?: number;
|
|
73
97
|
disabled?: boolean;
|
|
98
|
+
/** Do not open the file dialog on click; for a zone that wraps clickable content. @default false */
|
|
99
|
+
noClick?: boolean;
|
|
100
|
+
/** Do not open the file dialog on Enter or Space; pair with `noClick`. @default false */
|
|
101
|
+
noKeyboard?: boolean;
|
|
74
102
|
/** Primary line of the default body. */
|
|
75
103
|
label?: React.ReactNode;
|
|
76
104
|
/** Muted second line of the default body; derived from `accept` and `maxSize` when omitted. */
|
|
@@ -87,6 +115,8 @@ function Dropzone({
|
|
|
87
115
|
maxSize,
|
|
88
116
|
maxFiles,
|
|
89
117
|
disabled = false,
|
|
118
|
+
noClick = false,
|
|
119
|
+
noKeyboard = false,
|
|
90
120
|
label,
|
|
91
121
|
hint,
|
|
92
122
|
children,
|
|
@@ -100,6 +130,8 @@ function Dropzone({
|
|
|
100
130
|
maxSize,
|
|
101
131
|
maxFiles,
|
|
102
132
|
disabled,
|
|
133
|
+
noClick,
|
|
134
|
+
noKeyboard,
|
|
103
135
|
onDropAccepted: (files) => onFiles(files),
|
|
104
136
|
onDropRejected: (rejections) => onReject?.(rejections),
|
|
105
137
|
});
|
|
@@ -168,7 +200,7 @@ function Dropzone({
|
|
|
168
200
|
className: cn(
|
|
169
201
|
"group/dropzone flex flex-col items-center justify-center gap-2 p-6 text-center",
|
|
170
202
|
"rounded-[var(--ds-radius-lg)] border border-dashed border-[var(--ds-border)] bg-[var(--ds-card)]",
|
|
171
|
-
"cursor-pointer select-none outline-none",
|
|
203
|
+
noClick ? "select-none outline-none" : "cursor-pointer select-none outline-none",
|
|
172
204
|
"transition-[border-color,background-color] duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
|
|
173
205
|
"hover:border-[var(--ds-border-strong)] hover:bg-[var(--ds-hover)]",
|
|
174
206
|
"focus-visible:border-[var(--ds-ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ds-ring)]/40",
|
|
@@ -184,6 +216,8 @@ function Dropzone({
|
|
|
184
216
|
data-reject={isDragReject || undefined}
|
|
185
217
|
data-focused={isFocused || undefined}
|
|
186
218
|
data-disabled={disabled || undefined}
|
|
219
|
+
data-no-click={noClick || undefined}
|
|
220
|
+
data-no-keyboard={noKeyboard || undefined}
|
|
187
221
|
>
|
|
188
222
|
<input data-slot="dropzone-input" {...getInputProps()} />
|
|
189
223
|
{body}
|
|
@@ -29,6 +29,10 @@ export interface SectionCardProps extends Omit<CardProps, "title"> {
|
|
|
29
29
|
* SectionCard — a Card with a header row (title, description, actions), a
|
|
30
30
|
* body and an optional footer. It composes the Card parts rather than
|
|
31
31
|
* restyling them, so a SectionCard and a hand-built Card look identical.
|
|
32
|
+
*
|
|
33
|
+
* `render` is Card's and reaches the outer element, so a section card can be
|
|
34
|
+
* the landmark it is named after: `render={<section aria-labelledby={id} />}`
|
|
35
|
+
* with the same `id` on the title, or `render={<aside />}` for a side panel.
|
|
32
36
|
*/
|
|
33
37
|
function SectionCard({
|
|
34
38
|
title,
|
|
@@ -18,9 +18,38 @@ import { cn } from "@/lib/cn";
|
|
|
18
18
|
* - `onValueChange` receives `(value, eventDetails)`.
|
|
19
19
|
* - `SelectContent` defaults to the dropdown layout; pass
|
|
20
20
|
* `alignItemWithTrigger` for Base UI's native-like overlay of the selected item.
|
|
21
|
+
*
|
|
22
|
+
* ## Typing the value
|
|
23
|
+
*
|
|
24
|
+
* `null` is the cleared / placeholder state, so `onValueChange` always hands
|
|
25
|
+
* back `Value | null`, never bare `Value`. Name the value type on the element
|
|
26
|
+
* to keep that union at `string | null` instead of letting inference widen it:
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const [status, setStatus] = useState("");
|
|
30
|
+
*
|
|
31
|
+
* <Select<string>
|
|
32
|
+
* // "" is not a value any item carries; `|| null` is the placeholder state,
|
|
33
|
+
* // which is what makes SelectValue fall back to its `placeholder`.
|
|
34
|
+
* value={status || null}
|
|
35
|
+
* onValueChange={(next) => setStatus(next ?? "")} // next: string | null
|
|
36
|
+
* >
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Without the explicit `<string>` the type argument is inferred from `value`,
|
|
40
|
+
* so a `string | null` value widens `Value` and the handler's parameter turns
|
|
41
|
+
* into `string | null | null`. Multi-select narrows too:
|
|
42
|
+
* `<Select<string, true> multiple>` hands back `string[]`, never `null`.
|
|
43
|
+
*
|
|
44
|
+
* `SelectProps` is the same props type, for a wrapper that forwards them.
|
|
21
45
|
*/
|
|
46
|
+
export type SelectProps<
|
|
47
|
+
Value = string,
|
|
48
|
+
Multiple extends boolean | undefined = false,
|
|
49
|
+
> = SelectPrimitive.Root.Props<Value, Multiple>;
|
|
50
|
+
|
|
22
51
|
function Select<Value, Multiple extends boolean | undefined = false>(
|
|
23
|
-
props:
|
|
52
|
+
props: SelectProps<Value, Multiple>,
|
|
24
53
|
) {
|
|
25
54
|
return <SelectPrimitive.Root {...props} />;
|
|
26
55
|
}
|
package/src/components/sheet.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
|
|
4
4
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
-
import
|
|
5
|
+
import * as React from "react";
|
|
6
6
|
import { cn } from "@/lib/cn";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -12,9 +12,43 @@ import { cn } from "@/lib/cn";
|
|
|
12
12
|
* Description, Close); only the Popup's placement differs, chosen by `side`.
|
|
13
13
|
* `SheetContent` composes Portal + Backdrop + Popup so consumers keep writing
|
|
14
14
|
* `<SheetContent side="right">…</SheetContent>`.
|
|
15
|
+
*
|
|
16
|
+
* ## Non-modal side panels
|
|
17
|
+
*
|
|
18
|
+
* `modal={false}` is the help-panel / inspector case: a sheet read *alongside*
|
|
19
|
+
* the app rather than instead of it. No backdrop is rendered, focus is not
|
|
20
|
+
* trapped, page scroll is not locked, and the rest of the page stays clickable,
|
|
21
|
+
* so a user can keep working with the panel open. `modal="trap-focus"` is the
|
|
22
|
+
* middle setting Base UI offers: focus stays inside, the page still scrolls.
|
|
23
|
+
*
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <Sheet modal={false} open={helpOpen} onOpenChange={setHelpOpen}>
|
|
26
|
+
* <SheetContent side="right" disablePointerDismissal>…</SheetContent>
|
|
27
|
+
* </Sheet>
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* A non-modal sheet closes when focus or a press leaves it; pass
|
|
31
|
+
* `disablePointerDismissal` (a Base UI Root prop, forwarded) to keep it open
|
|
32
|
+
* until the user closes it. `modal` is on the Root because that is where Base
|
|
33
|
+
* UI reads it; `SheetContent` picks the backdrop up from there through context.
|
|
15
34
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
35
|
+
const SheetModalContext = React.createContext<boolean | "trap-focus">(true);
|
|
36
|
+
|
|
37
|
+
export interface SheetProps extends SheetPrimitive.Root.Props {
|
|
38
|
+
/**
|
|
39
|
+
* `true` (default) traps focus, locks page scroll and draws a backdrop.
|
|
40
|
+
* `false` draws no backdrop and traps nothing, for a panel read alongside
|
|
41
|
+
* the app. `"trap-focus"` keeps focus in but leaves the page scrollable.
|
|
42
|
+
*/
|
|
43
|
+
modal?: boolean | "trap-focus";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Sheet({ modal = true, ...props }: SheetProps) {
|
|
47
|
+
return (
|
|
48
|
+
<SheetModalContext.Provider value={modal}>
|
|
49
|
+
<SheetPrimitive.Root modal={modal} {...props} />
|
|
50
|
+
</SheetModalContext.Provider>
|
|
51
|
+
);
|
|
18
52
|
}
|
|
19
53
|
|
|
20
54
|
function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
|
|
@@ -85,12 +119,14 @@ function SheetContent({
|
|
|
85
119
|
container,
|
|
86
120
|
...props
|
|
87
121
|
}: SheetContentProps) {
|
|
122
|
+
const modal = React.useContext(SheetModalContext);
|
|
88
123
|
return (
|
|
89
124
|
<SheetPortal keepMounted={keepMounted} container={container}>
|
|
90
|
-
<SheetOverlay />
|
|
125
|
+
{modal === false ? null : <SheetOverlay />}
|
|
91
126
|
<SheetPrimitive.Popup
|
|
92
127
|
data-slot="sheet-content"
|
|
93
128
|
data-side={side}
|
|
129
|
+
data-modal={modal === false ? undefined : modal === true ? "" : modal}
|
|
94
130
|
className={cn(sheetVariants({ side }), className)}
|
|
95
131
|
{...props}
|
|
96
132
|
>
|