@lovett/ui 0.0.10 → 0.0.11

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.
@@ -11,9 +11,17 @@
11
11
  * Token discipline: all colors via public tokens. Tone variant gives
12
12
  * the value text an accent/success/warning/destructive/info color;
13
13
  * background is always neutral surface-overlay.
14
+ *
15
+ * `copyable` (ADR-145 amendment, 2026-09-04): the card becomes a button
16
+ * that copies `copyValue` (or the string `value`) and toasts
17
+ * "Copied <label>". Same hover/focus affordance as ValueChip and the
18
+ * copyable StatRow. Inert by default so a read-only KPI never advertises
19
+ * an interaction it doesn't have.
14
20
  */
15
21
 
16
22
  import type { ReactNode } from 'react'
23
+ import { cn } from './lib/utils'
24
+ import { copyText } from './lib/clipboard'
17
25
 
18
26
  export type MetricCardTone = 'neutral' | 'accent' | 'success' | 'warning' | 'destructive' | 'info'
19
27
 
@@ -46,6 +54,11 @@ export interface MetricCardProps {
46
54
  labelPosition?: 'top' | 'bottom'
47
55
  /** Optional className override for the outer container. */
48
56
  className?: string
57
+ /** Make the whole card a copy-to-clipboard button. */
58
+ copyable?: boolean
59
+ /** What a copyable card writes. Defaults to `value` when it is a string;
60
+ * REQUIRED when `value` is a node (there is nothing else to copy). */
61
+ copyValue?: string
49
62
  }
50
63
 
51
64
  export function MetricCard({
@@ -55,6 +68,8 @@ export function MetricCard({
55
68
  icon,
56
69
  labelPosition = 'bottom',
57
70
  className,
71
+ copyable = false,
72
+ copyValue,
58
73
  }: MetricCardProps) {
59
74
  const labelEl =
60
75
  labelPosition === 'top' ? (
@@ -89,29 +104,49 @@ export function MetricCard({
89
104
  </div>
90
105
  )
91
106
 
107
+ const body =
108
+ labelPosition === 'top' ? (
109
+ <>
110
+ {labelEl}
111
+ {valueEl}
112
+ </>
113
+ ) : (
114
+ <>
115
+ {valueEl}
116
+ {labelEl}
117
+ </>
118
+ )
119
+ const surfaceClass = className ?? 'flex flex-col gap-1 px-4 py-3 border'
120
+ const surfaceStyle = {
121
+ background: 'rgb(var(--surface-overlay-soft))',
122
+ borderColor: 'rgb(var(--border))',
123
+ borderRadius: 'var(--radius-lg)',
124
+ } as const
125
+
126
+ const text = copyValue ?? (typeof value === 'string' ? value : undefined)
127
+ if (copyable && text !== undefined) {
128
+ return (
129
+ <button
130
+ type="button"
131
+ onClick={() => void copyText(text, label)}
132
+ title={`Copy ${label}`}
133
+ aria-label={`Copy ${label}: ${text}`}
134
+ className={cn(
135
+ surfaceClass,
136
+ 'text-left cursor-pointer transition-[border-color,box-shadow]',
137
+ 'hover:border-[rgb(var(--border-strong))] hover:[box-shadow:var(--shadow-sm)]',
138
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
139
+ )}
140
+ style={surfaceStyle}
141
+ >
142
+ {body}
143
+ </button>
144
+ )
145
+ }
146
+
92
147
  return (
93
- <div
94
- className={
95
- className ??
96
- 'flex flex-col gap-1 px-4 py-3 border'
97
- }
98
- style={{
99
- background: 'rgb(var(--surface-overlay-soft))',
100
- borderColor: 'rgb(var(--border))',
101
- borderRadius: 'var(--radius-lg)',
102
- }}
103
- >
104
- {labelPosition === 'top' ? (
105
- <>
106
- {labelEl}
107
- {valueEl}
108
- </>
109
- ) : (
110
- <>
111
- {valueEl}
112
- {labelEl}
113
- </>
114
- )}
148
+ <div className={surfaceClass} style={surfaceStyle}>
149
+ {body}
115
150
  </div>
116
151
  )
117
152
  }
package/src/modal.tsx CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  } from 'react'
11
11
  import { X } from 'lucide-react'
12
12
  import { cn } from './lib/utils'
13
+ import { tabbablesWithin } from './lib/focus'
14
+ import { useLayer } from './lib/layer-stack'
13
15
 
14
16
  /**
15
17
  * Modal — a centered floating panel on top of a backdrop. Built on
@@ -56,47 +58,25 @@ import { cn } from './lib/utils'
56
58
  * the `.cs-frame`-scoped token layer and out of their current stacking /
57
59
  * inheritance context, which is a much larger change than an a11y fix
58
60
  * should make. Inerting `document.body`'s other children is also wrong:
59
- * `DropdownMenu`, `TagChipInput` and `FolderTreePicker` portal their
60
- * content THERE, so a select inside a modal would go dead. Focus trap it
61
- * is — the keyboard hole is closed; pointer and AT virtual-cursor access
61
+ * `DropdownMenu`, `Popover`, `TagChipInput` and `FolderTreePicker` portal
62
+ * their content THERE, so a select inside a modal would go dead. Focus trap
63
+ * it is — the keyboard hole is closed; pointer and AT virtual-cursor access
62
64
  * to the background remains, which is the documented residual.
65
+ *
66
+ * Escape and dismiss order come from the shared layer stack
67
+ * (`lib/layer-stack.ts`): the topmost open surface — modal, menu or
68
+ * popover — answers Escape alone.
63
69
  */
64
70
 
65
71
  /**
66
- * Open panels, outermost first. Only the topmost one answers Escape and
67
- * owns the tab ring, so nested modals do not both close on one Escape
68
- * and do not fight over focus.
69
- */
70
- const openPanels: HTMLElement[] = []
71
-
72
- /**
73
- * Tabbable candidates. Deliberately attribute-only — no geometry check.
74
- * `offsetWidth` / `getClientRects()` are always zero in jsdom, so a
75
- * visibility filter would empty this list under test and silently
76
- * disable the trap in exactly the environment that verifies it.
72
+ * Dismiss order lives in the shared layer stack (`lib/layer-stack.ts`),
73
+ * which this file used to own as a module-level `openPanels` array. Only
74
+ * the topmost layer answers Escape, so nested modals do not both close on
75
+ * one keypress — and now a DropdownMenu or Popover opened from inside a
76
+ * modal closes on its own first, leaving the modal open. The Tab trap asks
77
+ * for the topmost MODAL (`isTopOfKind`), so a popover above it does not
78
+ * switch the trap off.
77
79
  */
78
- const FOCUSABLE_SELECTOR = [
79
- 'a[href]',
80
- 'area[href]',
81
- 'button:not([disabled])',
82
- 'input:not([disabled]):not([type="hidden"])',
83
- 'select:not([disabled])',
84
- 'textarea:not([disabled])',
85
- 'iframe',
86
- 'summary',
87
- '[contenteditable="true"]',
88
- '[tabindex]',
89
- ].join(',')
90
-
91
- function tabbablesWithin(panel: HTMLElement): HTMLElement[] {
92
- return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
93
- (el) =>
94
- el.tabIndex >= 0 &&
95
- !el.hasAttribute('inert') &&
96
- el.getAttribute('aria-hidden') !== 'true' &&
97
- !el.closest('[hidden]'),
98
- )
99
- }
100
80
 
101
81
  interface ModalProps {
102
82
  isOpen: boolean
@@ -130,13 +110,10 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
130
110
  ? document.activeElement
131
111
  : null
132
112
 
133
- openPanels.push(panel)
134
113
  const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
135
114
  ;(initial ?? panel).focus()
136
115
 
137
116
  return () => {
138
- const at = openPanels.indexOf(panel)
139
- if (at !== -1) openPanels.splice(at, 1)
140
117
  // Restoring is the half that gets forgotten: without it, closing a
141
118
  // modal drops the caret to <body> and the next Tab restarts from
142
119
  // the top of the page.
@@ -144,21 +121,27 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
144
121
  }
145
122
  }, [isOpen])
146
123
 
147
- // Escape + tab trap. One document listener; the topmost-panel guard
148
- // keeps nested modals from both reacting.
124
+ // Escape: the shared stack dispatches it to the topmost layer only, and
125
+ // reads `onClose` through a ref so an inline arrow does not re-register
126
+ // (or reorder) the layer on every render.
127
+ const layer = useLayer({
128
+ enabled: isOpen,
129
+ kind: 'modal',
130
+ elementRef: panelRef,
131
+ onEscape: () => onClose(),
132
+ })
133
+
134
+ // Tab trap. One document listener; the topmost-MODAL guard keeps nested
135
+ // modals from both reacting, and keeps this trap live while a popover
136
+ // sits above it.
149
137
  useEffect(() => {
150
138
  if (!isOpen) return
151
139
  const panel = panelRef.current
152
140
  if (!panel) return
153
141
 
154
142
  const handleKeyDown = (e: KeyboardEvent) => {
155
- if (openPanels[openPanels.length - 1] !== panel) return
156
-
157
- if (e.key === 'Escape') {
158
- onClose()
159
- return
160
- }
161
143
  if (e.key !== 'Tab') return
144
+ if (!layer.isTopOfKind()) return
162
145
 
163
146
  const active = document.activeElement
164
147
  // Focus is outside the panel — almost always a body-level portal
@@ -199,7 +182,7 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
199
182
 
200
183
  document.addEventListener('keydown', handleKeyDown)
201
184
  return () => document.removeEventListener('keydown', handleKeyDown)
202
- }, [isOpen, onClose])
185
+ }, [isOpen, layer])
203
186
 
204
187
  if (!isOpen) return null
205
188
 
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Popover — an anchored, non-modal floating surface with focus management.
3
+ *
4
+ * Compound: `<Popover>` (state) + `<Popover.Trigger>` + `<Popover.Content>`.
5
+ * Controlled (`open` + `onOpenChange`) or uncontrolled (`defaultOpen`).
6
+ * Content portals to `document.body`, positions through
7
+ * `useAnchoredPosition` (flip + clamp), enters with `.ds-enter-pop` once
8
+ * positioned, and registers on the shared dismiss-layer stack — so Escape
9
+ * closes ONLY the topmost surface (a Popover inside a Modal closes before
10
+ * the Modal does) and an outside pointerdown closes it unless the pointer
11
+ * landed in a layer stacked above (a Select opened from inside it).
12
+ *
13
+ * Focus: opening moves focus into the content — the `[data-autofocus]`
14
+ * control if one is marked, else the first tabbable, else the content
15
+ * itself (`tabIndex={-1}`) — once the content is POSITIONED, not on the
16
+ * first frame: that frame is parked off-screen with `visibility: hidden`,
17
+ * and no browser will focus into a hidden subtree (the call is a silent
18
+ * no-op). Closing returns focus to the trigger, EXCEPT
19
+ * after an outside click, where the user has already put focus where they
20
+ * want it and yanking it back to the trigger would be wrong.
21
+ *
22
+ * Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
23
+ * the row / card / context-menu / palette pickers all sit in a popover with
24
+ * search + live counts). Second consumer is the workspace app, which has
25
+ * three hand-rolled popovers today (sidebar user menu, group-settings
26
+ * colour and icon pickers, CalculatorShell's AssumptionsPopover) — each a
27
+ * migration target. DropdownMenu stays a sibling on the same hook and stack
28
+ * rather than a Popover child: a menu does NOT move focus into itself on
29
+ * open, and 137 call sites depend on that.
30
+ *
31
+ * Trigger is an unstyled `<button type="button">` by default — Popover is a
32
+ * generic surface, the caller chooses the trigger's look. Pass `asChild` to
33
+ * make the single child element the trigger instead (a `<Button>`, a table
34
+ * cell, a chip): it receives the ref, the click handler and the ARIA
35
+ * attributes, and the DOM stays one element. The child must be keyboard-
36
+ * operable ON ITS OWN: Trigger adds ARIA + click only, no role, tabIndex or
37
+ * key handler. A native `<button>` / `<a href>` already is; a `<td>` or
38
+ * `<span>` needs `role="button"`, `tabIndex={0}` and its own Enter / Space
39
+ * handling, or only mouse users can open the popover.
40
+ *
41
+ * Content is `role="dialog"` (non-modal — no `aria-modal`) and needs a name
42
+ * from the caller (`aria-label` / `aria-labelledby`). Pass `role="none"`
43
+ * when the content IS the widget (a listbox, a menu) and would otherwise
44
+ * nest roles.
45
+ *
46
+ * Token discipline: the surface reads the INTERNAL `--popover` /
47
+ * `--popover-foreground` (allowed in packages/ui, ESLint-enforced elsewhere)
48
+ * plus public `--border`, `--shadow-lg`, `--radius-lg`. Geometry comes from
49
+ * the hook; motion from `.ds-enter-pop` (`--dur-fast` / `--ease-out`, honours
50
+ * prefers-reduced-motion in styles.css). No hex, no raw px in classes.
51
+ *
52
+ * Usage:
53
+ *
54
+ * <Popover>
55
+ * <Popover.Trigger asChild>
56
+ * <Button variant="secondary">Assignee</Button>
57
+ * </Popover.Trigger>
58
+ * <Popover.Content aria-label="Pick an assignee" align="start">
59
+ * <input data-autofocus placeholder="Search people…" />
60
+ * …
61
+ * </Popover.Content>
62
+ * </Popover>
63
+ *
64
+ * // Virtual anchor (ContextMenu later): no Trigger, position at a point.
65
+ * <Popover open={!!point} onOpenChange={…} anchorRect={point}>
66
+ * <Popover.Content role="none">…</Popover.Content>
67
+ * </Popover>
68
+ */
69
+
70
+ import {
71
+ cloneElement,
72
+ createContext,
73
+ forwardRef,
74
+ isValidElement,
75
+ useCallback,
76
+ useContext,
77
+ useEffect,
78
+ useId,
79
+ useMemo,
80
+ useRef,
81
+ useState,
82
+ type ButtonHTMLAttributes,
83
+ type CSSProperties,
84
+ type HTMLAttributes,
85
+ type MouseEvent as ReactMouseEvent,
86
+ type ReactNode,
87
+ type Ref,
88
+ type RefObject,
89
+ } from 'react'
90
+ import { createPortal } from 'react-dom'
91
+ import { cn } from './lib/utils'
92
+ import { composeRefs } from './lib/refs'
93
+ import { tabbablesWithin } from './lib/focus'
94
+ import { useLayer } from './lib/layer-stack'
95
+ import {
96
+ useAnchoredPosition,
97
+ useOutsideClick,
98
+ type AnchorAlign,
99
+ type AnchorRect,
100
+ type AnchorSide,
101
+ } from './lib/anchor'
102
+
103
+ /**
104
+ * The popover surface chrome, shared with DropdownMenuContent so a menu and
105
+ * a popover read as the same family. Inline rather than a stylesheet class
106
+ * because the surface tokens are internal and the primitives already own
107
+ * them here (Kbd, DropdownMenu follow the same pattern).
108
+ */
109
+ export const POPOVER_SURFACE_STYLE: CSSProperties = {
110
+ background: 'rgb(var(--popover))',
111
+ color: 'rgb(var(--popover-foreground))',
112
+ border: '1px solid rgb(var(--border))',
113
+ boxShadow: 'var(--shadow-lg)',
114
+ }
115
+
116
+ interface PopoverContextValue {
117
+ open: boolean
118
+ setOpen: (next: boolean) => void
119
+ triggerRef: RefObject<HTMLElement | null>
120
+ contentId: string
121
+ anchorRect: AnchorRect | null
122
+ /** Set when the close came from an outside pointerdown; skips focus return. */
123
+ interactedOutsideRef: RefObject<boolean>
124
+ }
125
+
126
+ const PopoverCtx = createContext<PopoverContextValue | null>(null)
127
+
128
+ function usePopoverCtx(consumer: string): PopoverContextValue {
129
+ const ctx = useContext(PopoverCtx)
130
+ if (!ctx) {
131
+ throw new Error(
132
+ `${consumer} must be rendered inside <Popover>. ` +
133
+ `Wrap your trigger + content with the Popover root component.`,
134
+ )
135
+ }
136
+ return ctx
137
+ }
138
+
139
+ export interface PopoverProps {
140
+ /** Controlled open state. Omit for internal state. */
141
+ open?: boolean
142
+ defaultOpen?: boolean
143
+ /** Fires whenever open transitions. */
144
+ onOpenChange?: (open: boolean) => void
145
+ /**
146
+ * Virtual anchor. When non-null, Content positions against this rect
147
+ * instead of the Trigger — a right-click point, a caret, a cell.
148
+ */
149
+ anchorRect?: AnchorRect | null
150
+ children: ReactNode
151
+ }
152
+
153
+ function PopoverRoot({
154
+ open: controlledOpen,
155
+ defaultOpen,
156
+ onOpenChange,
157
+ anchorRect = null,
158
+ children,
159
+ }: PopoverProps) {
160
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(Boolean(defaultOpen))
161
+ const isControlled = controlledOpen !== undefined
162
+ const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen
163
+ const triggerRef = useRef<HTMLElement | null>(null)
164
+ const interactedOutsideRef = useRef(false)
165
+ const generatedId = useId()
166
+ const contentId = `popover-${generatedId.replace(/:/g, '')}`
167
+
168
+ const setOpen = useCallback(
169
+ (next: boolean) => {
170
+ if (!isControlled) setUncontrolledOpen(next)
171
+ onOpenChange?.(next)
172
+ },
173
+ [isControlled, onOpenChange],
174
+ )
175
+
176
+ const value = useMemo<PopoverContextValue>(
177
+ () => ({ open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef }),
178
+ [open, setOpen, contentId, anchorRect],
179
+ )
180
+
181
+ return <PopoverCtx.Provider value={value}>{children}</PopoverCtx.Provider>
182
+ }
183
+
184
+ export interface PopoverTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
185
+ /**
186
+ * Render the single child element as the trigger instead of a `<button>`.
187
+ * The child receives the ref, click handler and ARIA attributes; the DOM
188
+ * stays one element. It must be focusable and keyboard-operable on its
189
+ * own (a `<button>`, an `<a href>`, or `role="button"` + `tabIndex={0}` +
190
+ * Enter / Space handling): Trigger adds no role, tabIndex or key handler.
191
+ */
192
+ asChild?: boolean
193
+ }
194
+
195
+ type TriggerChildProps = HTMLAttributes<HTMLElement> & {
196
+ ref?: Ref<HTMLElement>
197
+ 'data-state'?: string
198
+ }
199
+
200
+ const PopoverTrigger = forwardRef<HTMLButtonElement, PopoverTriggerProps>(
201
+ function PopoverTrigger({ asChild, onClick, children, ...rest }, forwardedRef) {
202
+ const { open, setOpen, triggerRef, contentId } = usePopoverCtx('Popover.Trigger')
203
+
204
+ const ariaProps = {
205
+ 'aria-haspopup': 'dialog',
206
+ 'aria-expanded': open,
207
+ 'aria-controls': open ? contentId : undefined,
208
+ 'data-state': open ? 'open' : 'closed',
209
+ } as const
210
+
211
+ if (asChild) {
212
+ if (!isValidElement<TriggerChildProps>(children)) {
213
+ throw new Error('Popover.Trigger with `asChild` expects exactly one element child.')
214
+ }
215
+ const child = children
216
+ const childOnClick = child.props.onClick
217
+ return cloneElement(child, {
218
+ ...rest,
219
+ ...ariaProps,
220
+ ref: composeRefs<HTMLElement>(child.props.ref, forwardedRef, triggerRef),
221
+ onClick: (event: ReactMouseEvent<HTMLElement>) => {
222
+ childOnClick?.(event)
223
+ onClick?.(event as ReactMouseEvent<HTMLButtonElement>)
224
+ if (!event.defaultPrevented) setOpen(!open)
225
+ },
226
+ })
227
+ }
228
+
229
+ return (
230
+ <button
231
+ ref={composeRefs<HTMLElement>(forwardedRef, triggerRef)}
232
+ type="button"
233
+ {...ariaProps}
234
+ onClick={(event) => {
235
+ onClick?.(event)
236
+ if (!event.defaultPrevented) setOpen(!open)
237
+ }}
238
+ {...rest}
239
+ >
240
+ {children}
241
+ </button>
242
+ )
243
+ },
244
+ )
245
+
246
+ export interface PopoverContentProps
247
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'role' | 'children'> {
248
+ children: ReactNode
249
+ /** Preferred side of the anchor. Default `bottom`. Flips when it cannot fit. */
250
+ side?: AnchorSide
251
+ /** Alignment along the anchor's cross axis. Default `start`. */
252
+ align?: AnchorAlign
253
+ /** Gap from the anchor, px. Default 6. */
254
+ offset?: number
255
+ /** Shift along the alignment axis, px. Default 0. */
256
+ alignOffset?: number
257
+ /** Default true. */
258
+ flip?: boolean
259
+ /** Default true. */
260
+ clampToViewport?: boolean
261
+ /**
262
+ * `dialog` (default) — a named, non-modal dialog; give it `aria-label` or
263
+ * `aria-labelledby`. `none` — no role attribute, for content that is
264
+ * itself the widget.
265
+ */
266
+ role?: 'dialog' | 'none'
267
+ /** Skip the surface chrome (background / border / shadow / radius / padding). */
268
+ unstyled?: boolean
269
+ }
270
+
271
+ const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
272
+ function PopoverContent(
273
+ {
274
+ children,
275
+ side = 'bottom',
276
+ align = 'start',
277
+ offset = 6,
278
+ alignOffset = 0,
279
+ flip = true,
280
+ clampToViewport = true,
281
+ role = 'dialog',
282
+ unstyled,
283
+ className,
284
+ style,
285
+ ...rest
286
+ },
287
+ forwardedRef,
288
+ ) {
289
+ const { open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef } =
290
+ usePopoverCtx('Popover.Content')
291
+ const contentRef = useRef<HTMLDivElement | null>(null)
292
+
293
+ const {
294
+ ref: positionRef,
295
+ style: positionStyle,
296
+ placement,
297
+ positioned,
298
+ } = useAnchoredPosition<HTMLDivElement>({
299
+ anchorRef: triggerRef,
300
+ anchorRect,
301
+ side,
302
+ align,
303
+ offset,
304
+ alignOffset,
305
+ flip,
306
+ clampToViewport,
307
+ enabled: open,
308
+ })
309
+
310
+ const layer = useLayer({
311
+ enabled: open,
312
+ kind: 'popover',
313
+ elementRef: contentRef,
314
+ onEscape: () => setOpen(false),
315
+ })
316
+
317
+ useOutsideClick(
318
+ [triggerRef, contentRef],
319
+ () => {
320
+ interactedOutsideRef.current = true
321
+ setOpen(false)
322
+ },
323
+ { enabled: open, layer },
324
+ )
325
+
326
+ // Focus in once the content is POSITIONED, back to the trigger on close.
327
+ // Not on `open`: the first open frame is the parked one (off-screen,
328
+ // `visibility: hidden`), and Chromium, Firefox and WebKit all refuse to
329
+ // focus into a visibility:hidden subtree, so a focus() there is a silent
330
+ // no-op and focus stays on the trigger. `positioned` flips true in the
331
+ // re-render the measuring layout effect triggers, after the parked frame
332
+ // has already flushed its passive effects. jsdom does not enforce the
333
+ // rule, which is why the test records `data-positioned` at focus time.
334
+ // Keyed on state, not an inline callback, so `onOpenChange` identity
335
+ // churn never re-runs it mid-interaction.
336
+ useEffect(() => {
337
+ if (!open || !positioned) return
338
+ const node = contentRef.current
339
+ if (!node) return
340
+ interactedOutsideRef.current = false
341
+ // Captured now, not in the cleanup: the trigger does not change while
342
+ // the popover is open, and a remounted one would be disconnected.
343
+ const trigger = triggerRef.current
344
+ const previous =
345
+ document.activeElement instanceof HTMLElement &&
346
+ document.activeElement !== document.body
347
+ ? document.activeElement
348
+ : null
349
+ const initial =
350
+ node.querySelector<HTMLElement>('[data-autofocus]') ??
351
+ tabbablesWithin(node)[0] ??
352
+ node
353
+ initial.focus()
354
+
355
+ return () => {
356
+ if (interactedOutsideRef.current) return
357
+ // The content is gone by now, so a focus that was inside it has
358
+ // already fallen to <body>. Anything else means the user moved on.
359
+ const active = document.activeElement
360
+ if (active !== null && active !== document.body && !node.contains(active)) return
361
+ const target = trigger ?? previous
362
+ if (target?.isConnected) target.focus()
363
+ }
364
+ }, [open, positioned, triggerRef, interactedOutsideRef])
365
+
366
+ const ref = useMemo(
367
+ () => composeRefs<HTMLDivElement>(forwardedRef, contentRef, positionRef),
368
+ [forwardedRef, positionRef],
369
+ )
370
+
371
+ if (!open || typeof document === 'undefined') return null
372
+
373
+ return createPortal(
374
+ <div
375
+ ref={ref}
376
+ id={contentId}
377
+ role={role === 'none' ? undefined : role}
378
+ tabIndex={-1}
379
+ data-slot="popover-content"
380
+ data-state="open"
381
+ data-side={placement?.side}
382
+ data-align={placement?.align}
383
+ data-positioned={positioned ? 'true' : 'false'}
384
+ className={cn(
385
+ 'fixed z-[100] outline-none',
386
+ !unstyled && 'rounded-[var(--radius-lg)] p-3',
387
+ positioned && 'ds-enter-pop',
388
+ className,
389
+ )}
390
+ style={{
391
+ ...positionStyle,
392
+ ...(unstyled ? undefined : POPOVER_SURFACE_STYLE),
393
+ ...style,
394
+ }}
395
+ {...rest}
396
+ >
397
+ {children}
398
+ </div>,
399
+ document.body,
400
+ )
401
+ },
402
+ )
403
+
404
+ export const Popover = Object.assign(PopoverRoot, {
405
+ Trigger: PopoverTrigger,
406
+ Content: PopoverContent,
407
+ })