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.
@@ -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 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,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 ? HeatMapCanvas : HeatMapSvg;
97
- const rows = data as ChartSeries[];
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 ? CalendarCanvas : CalendarSvg;
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 ? TreeMapCanvas : TreeMapSvg;
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
- <SunburstSvg
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 "scatter" | "scatterplot" | "heatmap"
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
- 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(
@@ -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: SelectPrimitive.Root.Props<Value, Multiple>,
52
+ props: SelectProps<Value, Multiple>,
24
53
  ) {
25
54
  return <SelectPrimitive.Root {...props} />;
26
55
  }
@@ -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 type * as React from "react";
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
- function Sheet(props: SheetPrimitive.Root.Props) {
17
- return <SheetPrimitive.Root {...props} />;
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
  >