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.
@@ -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
  >
@@ -4,7 +4,9 @@ import {
4
4
  DndContext,
5
5
  DragOverlay,
6
6
  KeyboardSensor,
7
+ MouseSensor,
7
8
  PointerSensor,
9
+ TouchSensor,
8
10
  closestCenter,
9
11
  useSensor,
10
12
  useSensors,
@@ -40,8 +42,29 @@ import { cn } from "@/lib/cn";
40
42
  * By default the whole row is the drag activator. Pass `handle` to restrict
41
43
  * dragging to a `<SortableHandle>` (or any element given `handleProps`) so
42
44
  * buttons and inputs inside a row stay clickable.
45
+ *
46
+ * ## Touch
47
+ *
48
+ * A pointer drag and a touch scroll start the same way, so something has to
49
+ * say which one a finger means. In handle mode only the small handle is
50
+ * `touch-none`, so the rest of the row scrolls: nothing to decide. In
51
+ * whole-row mode the whole row is `touch-none`, which is why a touch list
52
+ * whose rows are the activators cannot be scrolled by dragging over them.
53
+ *
54
+ * `touchScroll` is the way out, and it defaults to whichever of those is
55
+ * already true — `true` in handle mode, `false` in whole-row mode, so existing
56
+ * lists behave exactly as before. Setting it `true` on a whole-row list drops
57
+ * `touch-none` and swaps the PointerSensor for a MouseSensor plus a
58
+ * TouchSensor with delay activation: a quick swipe scrolls, a press held for
59
+ * {@link TOUCH_DELAY_MS} picks the row up. `false` in handle mode is the
60
+ * opposite trade — the row never scrolls under a finger.
43
61
  */
44
62
 
63
+ /** How long a finger must rest on a row before it becomes a drag. */
64
+ const TOUCH_DELAY_MS = 250;
65
+ /** How far it may slip in that time and still count as a press, not a scroll. */
66
+ const TOUCH_TOLERANCE_PX = 8;
67
+
45
68
  /** Spread onto the element that should start a drag. */
46
69
  export interface SortableHandleProps extends Partial<DraggableAttributes> {
47
70
  ref: (element: HTMLElement | null) => void;
@@ -63,17 +86,40 @@ export interface SortableRenderState {
63
86
  isDragging: boolean;
64
87
  }
65
88
 
89
+ /** What moved where, passed to `onReorder` alongside the reordered array. */
90
+ export interface SortableReorderDetails {
91
+ /** Index the item came from, in the array as it was passed in. */
92
+ from: number;
93
+ /** Index it landed on. */
94
+ to: number;
95
+ /** Id of the item that moved (`getId` of the dragged item). */
96
+ activeId: UniqueIdentifier;
97
+ /** Id of the item it was dropped onto. */
98
+ overId: UniqueIdentifier;
99
+ }
100
+
66
101
  export interface SortableListProps<T> extends Omit<React.ComponentProps<"ul">, "children"> {
67
102
  items: readonly T[];
68
103
  /** Stable id per item; must be unique within the list. */
69
104
  getId: (item: T) => UniqueIdentifier;
70
- /** Called with the reordered array after a successful drop. */
71
- onReorder: (next: T[]) => void;
105
+ /**
106
+ * Called with the reordered array after a successful drop, and with the move
107
+ * itself — `{ from, to, activeId, overId }` — for a server that persists a
108
+ * position rather than the whole list.
109
+ */
110
+ onReorder: (next: T[], details: SortableReorderDetails) => void;
72
111
  renderItem: (item: T, state: SortableRenderState) => React.ReactNode;
73
112
  /** Layout axis; also selects the sorting strategy. */
74
113
  orientation?: "vertical" | "horizontal";
75
114
  /** Only a `<SortableHandle>` (or an element given `handleProps`) starts a drag. */
76
115
  handle?: boolean;
116
+ /**
117
+ * Let a finger scroll the page over the rows. Defaults to `handle`: `true`
118
+ * in handle mode, `false` in whole-row mode, which is what each already did.
119
+ * `true` on a whole-row list drops `touch-none` and uses a TouchSensor with
120
+ * delay activation, so a swipe scrolls and a held press drags.
121
+ */
122
+ touchScroll?: boolean;
77
123
  /** Disables every sensor and hides the handles. */
78
124
  disabled?: boolean;
79
125
  /** Class name for each item wrapper. */
@@ -106,11 +152,12 @@ interface SortableItemProps<T> {
106
152
  item: T;
107
153
  handle: boolean;
108
154
  disabled: boolean;
155
+ touchScroll: boolean;
109
156
  renderItem: SortableListProps<T>["renderItem"];
110
157
  className: string | undefined;
111
158
  }
112
159
 
113
- function SortableItem<T>({ id, item, handle, disabled, renderItem, className }: SortableItemProps<T>) {
160
+ function SortableItem<T>({ id, item, handle, disabled, touchScroll, renderItem, className }: SortableItemProps<T>) {
114
161
  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } =
115
162
  useSortable({ id, disabled });
116
163
 
@@ -134,7 +181,9 @@ function SortableItem<T>({ id, item, handle, disabled, renderItem, className }:
134
181
  style={{ transform: CSS.Translate.toString(transform), transition }}
135
182
  className={cn(
136
183
  itemClassName,
137
- !handle && !disabled && "cursor-grab touch-none data-[dragging]:cursor-grabbing",
184
+ !handle && !disabled && "cursor-grab data-[dragging]:cursor-grabbing",
185
+ // touch-action is what decides whether a finger scrolls or drags.
186
+ !touchScroll && !disabled && "touch-none",
138
187
  className,
139
188
  )}
140
189
  {...rowActivator}
@@ -184,15 +233,27 @@ function SortableList<T>({
184
233
  orientation = "vertical",
185
234
  handle = false,
186
235
  disabled = false,
236
+ touchScroll = handle,
187
237
  itemClassName: itemClass,
188
238
  className,
189
239
  ...props
190
240
  }: SortableListProps<T>) {
191
241
  const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
192
- const sensors = useSensors(
193
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
194
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
195
- );
242
+ // Only a whole-row list needs the split sensors: in handle mode the row is
243
+ // already scrollable because only the handle is touch-none, and swapping
244
+ // sensors there would put a 250 ms delay on the handle for no gain.
245
+ const delayTouch = touchScroll && !handle;
246
+ // Every sensor is created on every render (they are hooks); which ones are
247
+ // handed to DndContext is what changes.
248
+ const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 4 } });
249
+ const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 4 } });
250
+ const touchSensor = useSensor(TouchSensor, {
251
+ activationConstraint: { delay: TOUCH_DELAY_MS, tolerance: TOUCH_TOLERANCE_PX },
252
+ });
253
+ const keyboardSensor = useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates });
254
+ const pointerSensors = useSensors(pointerSensor, keyboardSensor);
255
+ const splitSensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
256
+ const sensors = delayTouch ? splitSensors : pointerSensors;
196
257
  const ids = React.useMemo(() => items.map(getId), [items, getId]);
197
258
  const activeItem = activeId === null ? undefined : items[ids.indexOf(activeId)];
198
259
 
@@ -207,7 +268,12 @@ function SortableList<T>({
207
268
  const from = ids.indexOf(active.id);
208
269
  const to = ids.indexOf(over.id);
209
270
  if (from < 0 || to < 0) return;
210
- onReorder(arrayMove([...items], from, to));
271
+ onReorder(arrayMove([...items], from, to), {
272
+ from,
273
+ to,
274
+ activeId: active.id,
275
+ overId: over.id,
276
+ });
211
277
  },
212
278
  [ids, items, onReorder],
213
279
  );
@@ -230,6 +296,7 @@ function SortableList<T>({
230
296
  data-slot="sortable-list"
231
297
  data-orientation={orientation}
232
298
  data-disabled={disabled || undefined}
299
+ data-touch-scroll={touchScroll ? "" : undefined}
233
300
  className={cn(
234
301
  "flex list-none gap-2 p-0",
235
302
  orientation === "horizontal" ? "flex-row flex-wrap" : "flex-col",
@@ -244,6 +311,7 @@ function SortableList<T>({
244
311
  item={item}
245
312
  handle={handle}
246
313
  disabled={disabled}
314
+ touchScroll={touchScroll}
247
315
  renderItem={renderItem}
248
316
  className={itemClass}
249
317
  />
@@ -6,21 +6,38 @@ import { cn } from "@/lib/cn";
6
6
  export interface SpinnerProps extends React.ComponentProps<"span"> {
7
7
  /** Diameter in px. Defaults to 16. */
8
8
  size?: number;
9
+ /**
10
+ * `inverse` for a spinner on a filled surface — inside a primary or
11
+ * destructive Button, on a toast, on any `-foreground` pairing. The default
12
+ * ring is `--ds-border` with a `--ds-primary` leading edge, which all but
13
+ * disappears on a primary fill; `inverse` draws both from
14
+ * `--ds-primary-foreground` instead.
15
+ */
16
+ tone?: "default" | "inverse";
9
17
  }
10
18
 
11
19
  /**
12
- * Spinner — a ring with a primary-coloured leading edge. Announces itself as
20
+ * Spinner — a ring with a coloured leading edge. Announces itself as
13
21
  * `role="status"` with a "Loading" label; pass `aria-label` to override.
22
+ *
23
+ * ```tsx
24
+ * <Button disabled>
25
+ * <Spinner tone="inverse" /> Saving…
26
+ * </Button>
27
+ * ```
14
28
  */
15
- function Spinner({ className, size = 16, style, ...props }: SpinnerProps) {
29
+ function Spinner({ className, size = 16, tone = "default", style, ...props }: SpinnerProps) {
16
30
  return (
17
31
  <span
18
32
  data-slot="spinner"
33
+ data-tone={tone}
19
34
  role="status"
20
35
  aria-label="Loading"
21
36
  className={cn(
22
- "inline-block shrink-0 animate-spin rounded-[var(--ds-radius-full)]",
23
- "border-2 border-[var(--ds-border)] border-t-[var(--ds-primary)]",
37
+ "inline-block shrink-0 animate-spin rounded-[var(--ds-radius-full)] border-2",
38
+ tone === "inverse"
39
+ ? "border-[var(--ds-primary-foreground)]/35 border-t-[var(--ds-primary-foreground)]"
40
+ : "border-[var(--ds-border)] border-t-[var(--ds-primary)]",
24
41
  className,
25
42
  )}
26
43
  style={{ width: size, height: size, ...style }}
@@ -3,12 +3,25 @@
3
3
  import type * as React from "react";
4
4
  import { cn } from "@/lib/cn";
5
5
 
6
- type StatusDotTone = "accent" | "success" | "warning" | "danger" | "info" | "neutral";
6
+ /**
7
+ * The error tone is `destructive`, the same spelling Button, Alert and Badge
8
+ * use. `danger` is a deprecated alias of it and paints the same.
9
+ */
10
+ type StatusDotTone =
11
+ | "accent"
12
+ | "success"
13
+ | "warning"
14
+ | "destructive"
15
+ /** @deprecated Use `destructive`; this alias paints the same. */
16
+ | "danger"
17
+ | "info"
18
+ | "neutral";
7
19
 
8
20
  const TONE_CLASS: Record<StatusDotTone, string> = {
9
21
  accent: "bg-[var(--ds-primary)]",
10
22
  success: "bg-[var(--ds-success)]",
11
23
  warning: "bg-[var(--ds-warning)]",
24
+ destructive: "bg-[var(--ds-destructive)]",
12
25
  danger: "bg-[var(--ds-destructive)]",
13
26
  info: "bg-[var(--ds-info)]",
14
27
  neutral: "bg-[var(--ds-text-tertiary)]",
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export type { AlertProps } from "@/components/alert";
11
11
  export { Avatar, AvatarFallback, AvatarImage } from "@/components/avatar";
12
12
  export { Badge, badgeVariants } from "@/components/badge";
13
13
  export type { BadgeProps } from "@/components/badge";
14
+ export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/breadcrumb";
15
+ export type { BreadcrumbLinkProps } from "@/components/breadcrumb";
14
16
  export { ButtonLink } from "@/components/button-link";
15
17
  export type { ButtonLinkProps } from "@/components/button-link";
16
18
  export { Button, buttonVariants } from "@/components/button";
@@ -69,14 +71,14 @@ export type { SectionCardProps } from "@/components/section-card";
69
71
  export { Segmented } from "@/components/segmented";
70
72
  export type { SegmentedOption, SegmentedProps } from "@/components/segmented";
71
73
  export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/select";
72
- export type { SelectContentProps, SelectTriggerProps } from "@/components/select";
74
+ export type { SelectContentProps, SelectProps, SelectTriggerProps } from "@/components/select";
73
75
  export { Separator } from "@/components/separator";
74
76
  export type { SeparatorProps } from "@/components/separator";
75
77
  export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, sheetVariants } from "@/components/sheet";
76
- export type { SheetContentProps } from "@/components/sheet";
78
+ export type { SheetContentProps, SheetProps } from "@/components/sheet";
77
79
  export { Skeleton } from "@/components/skeleton";
78
80
  export { SortableHandle, SortableList, arrayMove } from "@/components/sortable-list";
79
- export type { SortableHandleProps, SortableListProps, SortableRenderState } from "@/components/sortable-list";
81
+ export type { SortableHandleProps, SortableListProps, SortableRenderState, SortableReorderDetails } from "@/components/sortable-list";
80
82
  export { Spinner } from "@/components/spinner";
81
83
  export type { SpinnerProps } from "@/components/spinner";
82
84
  export { StatusDot } from "@/components/status-dot";
@@ -0,0 +1,39 @@
1
+ import type * as React from "react";
2
+
3
+ /**
4
+ * The `render` prop for the components that are plain markup rather than Base
5
+ * UI primitives (Card, SectionCard, ButtonLink, BreadcrumbLink).
6
+ *
7
+ * Base UI parts take their own `render` and need nothing from here. The rest
8
+ * are a `<div>` or an `<a>` this package writes itself, and a consumer
9
+ * sometimes needs a different element under the same styling: a Card that is
10
+ * the page's `<aside>` landmark, or a link that is the router's `<Link>`.
11
+ * Passing an element rather than a component keeps the call site readable and
12
+ * lets the caller set props on it (`render={<Link to="/x" />}`).
13
+ *
14
+ * `props` wins over the passed element's own props, so the `className` and
15
+ * handlers a component computed are not silently dropped, while the element's
16
+ * own props (`href`, `to`, `aria-labelledby`) come through untouched. What a
17
+ * component lets a *caller* override is decided by the order it builds `props`
18
+ * in, not here. Children are `children`, falling back to the element's.
19
+ */
20
+ export type RenderProp = React.ReactElement<Record<string, unknown>>;
21
+
22
+ export function renderWith(
23
+ render: RenderProp | undefined,
24
+ props: Record<string, unknown>,
25
+ children: React.ReactNode,
26
+ Fallback: React.ElementType,
27
+ ): React.ReactElement {
28
+ if (!render) {
29
+ const Element = Fallback;
30
+ return <Element {...props}>{children}</Element>;
31
+ }
32
+ const Element = render.type as React.ElementType;
33
+ const given = render.props as { children?: React.ReactNode };
34
+ return (
35
+ <Element {...(render.props as Record<string, unknown>)} {...props}>
36
+ {children ?? given.children}
37
+ </Element>
38
+ );
39
+ }