@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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Anchored positioning — the ONE measure / flip / clamp routine behind every
3
+ * floating surface in @lovett/ui.
4
+ *
5
+ * Four primitives (DropdownMenu, FilterDropdown, FolderTreePicker,
6
+ * TagChipInput) each hand-rolled the same `useLayoutEffect` +
7
+ * `getBoundingClientRect` + clamp + resize/scroll-listener block, with four
8
+ * slightly different gaps: no flip at a viewport edge; no clamp; measuring in
9
+ * a passive effect so the first paint was wrong; `top`/`bottom` only. This
10
+ * module is the single implementation. DropdownMenu is rebuilt on it in the
11
+ * same change; the other three keep their local copies for now (follow-up).
12
+ *
13
+ * Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager)
14
+ * with the workspace app as the first consumer — DropdownMenu and Popover
15
+ * position through this hook, so every menu in the app is on it. Select /
16
+ * Combobox / Tooltip / ContextMenu build on it next.
17
+ *
18
+ * No Floating UI, no Popper (CLAUDE.md §1 allowed deps). Three pieces:
19
+ * • `computeAnchoredPosition` — pure math, unit-tested without a DOM.
20
+ * • `useAnchoredPosition` — measures, positions, tracks resize / scroll /
21
+ * size changes. Anchor is an element (`anchorRef`) OR a virtual rect
22
+ * (`anchorRect`) — ContextMenu passes the right-click point as
23
+ * `{ left: e.clientX, top: e.clientY, width: 0, height: 0 }`.
24
+ * • `useOutsideClick` — pointerdown outside a set of targets, aware of
25
+ * layers stacked above (a nested Select's listbox is not "outside").
26
+ *
27
+ * Two-phase render, unchanged from the DropdownMenu it replaces: the first
28
+ * paint measures the content parked at (-99999, -99999) with
29
+ * `visibility: hidden`, the layout effect computes the real coordinates, and
30
+ * the second paint reveals it. Components gate their entry animation on
31
+ * `positioned` so `.ds-enter-pop` never plays from the parked location.
32
+ *
33
+ * Token discipline: the hook emits geometry only (`position: fixed` +
34
+ * `left` / `top` in px). Surface colour, radius, shadow, padding and the
35
+ * motion class stay with the component.
36
+ *
37
+ * Usage:
38
+ *
39
+ * const { ref, style, placement, positioned } =
40
+ * useAnchoredPosition<HTMLDivElement>({
41
+ * anchorRef: triggerRef,
42
+ * side: 'bottom',
43
+ * align: 'start',
44
+ * offset: 6,
45
+ * enabled: open,
46
+ * })
47
+ * return createPortal(
48
+ * <div
49
+ * ref={ref}
50
+ * style={style}
51
+ * data-side={placement?.side}
52
+ * className={cn(positioned && 'ds-enter-pop')}
53
+ * >
54
+ * …
55
+ * </div>,
56
+ * document.body,
57
+ * )
58
+ */
59
+
60
+ import {
61
+ useCallback,
62
+ useEffect,
63
+ useLayoutEffect,
64
+ useRef,
65
+ useState,
66
+ type CSSProperties,
67
+ type RefObject,
68
+ } from 'react'
69
+ import type { LayerHandle } from './layer-stack'
70
+
71
+ export type AnchorSide = 'top' | 'bottom' | 'left' | 'right'
72
+ export type AnchorAlign = 'start' | 'center' | 'end'
73
+
74
+ /** Viewport-relative rect. `DOMRect` satisfies it; so does a point. */
75
+ export interface AnchorRect {
76
+ readonly left: number
77
+ readonly top: number
78
+ readonly width: number
79
+ readonly height: number
80
+ }
81
+
82
+ export interface AnchorSize {
83
+ readonly width: number
84
+ readonly height: number
85
+ }
86
+
87
+ /** The side and alignment actually used after flipping. */
88
+ export interface AnchorPlacement {
89
+ readonly side: AnchorSide
90
+ readonly align: AnchorAlign
91
+ }
92
+
93
+ export interface AnchoredPositionOptions {
94
+ /** Preferred side of the anchor. Default `bottom`. */
95
+ side?: AnchorSide
96
+ /** Alignment along the anchor's cross axis. Default `start`. */
97
+ align?: AnchorAlign
98
+ /** Gap between anchor and floating element along `side`, px. Default 6. */
99
+ offset?: number
100
+ /** Shift along the alignment axis, px. Default 0. */
101
+ alignOffset?: number
102
+ /**
103
+ * Flip to the opposite side when the preferred side cannot fit and the
104
+ * opposite side fits — or simply has more room. Default true.
105
+ */
106
+ flip?: boolean
107
+ /** Keep the floating element inside the viewport, `gutter` px in. Default true. */
108
+ clampToViewport?: boolean
109
+ /** Viewport inset used by both flip and clamp, px. Default 8. */
110
+ gutter?: number
111
+ }
112
+
113
+ export interface AnchoredPosition {
114
+ readonly left: number
115
+ readonly top: number
116
+ readonly placement: AnchorPlacement
117
+ }
118
+
119
+ const OPPOSITE: Record<AnchorSide, AnchorSide> = {
120
+ top: 'bottom',
121
+ bottom: 'top',
122
+ left: 'right',
123
+ right: 'left',
124
+ }
125
+
126
+ function isVertical(side: AnchorSide): boolean {
127
+ return side === 'top' || side === 'bottom'
128
+ }
129
+
130
+ function clampNumber(value: number, min: number, max: number): number {
131
+ return Math.max(min, Math.min(value, max))
132
+ }
133
+
134
+ /**
135
+ * Pure placement math. Given the anchor rect, the floating element's size
136
+ * and the viewport size, returns fixed-position coordinates plus the
137
+ * placement actually used. No DOM — this is what the tests pin.
138
+ */
139
+ export function computeAnchoredPosition(
140
+ anchor: AnchorRect,
141
+ floating: AnchorSize,
142
+ viewport: AnchorSize,
143
+ options: AnchoredPositionOptions = {},
144
+ ): AnchoredPosition {
145
+ const {
146
+ side: preferred = 'bottom',
147
+ align = 'start',
148
+ offset = 6,
149
+ alignOffset = 0,
150
+ flip = true,
151
+ clampToViewport = true,
152
+ gutter = 8,
153
+ } = options
154
+
155
+ const anchorRight = anchor.left + anchor.width
156
+ const anchorBottom = anchor.top + anchor.height
157
+
158
+ // Room on each side of the anchor, inside the gutter.
159
+ const room: Record<AnchorSide, number> = {
160
+ top: anchor.top - gutter,
161
+ bottom: viewport.height - anchorBottom - gutter,
162
+ left: anchor.left - gutter,
163
+ right: viewport.width - anchorRight - gutter,
164
+ }
165
+ const needed = (side: AnchorSide): number =>
166
+ (isVertical(side) ? floating.height : floating.width) + offset
167
+
168
+ let side = preferred
169
+ if (flip && room[preferred] < needed(preferred)) {
170
+ const opposite = OPPOSITE[preferred]
171
+ if (room[opposite] >= needed(opposite) || room[opposite] > room[preferred]) {
172
+ side = opposite
173
+ }
174
+ }
175
+
176
+ let left: number
177
+ let top: number
178
+
179
+ if (isVertical(side)) {
180
+ top =
181
+ side === 'bottom'
182
+ ? anchorBottom + offset
183
+ : anchor.top - floating.height - offset
184
+ switch (align) {
185
+ case 'start':
186
+ left = anchor.left + alignOffset
187
+ break
188
+ case 'center':
189
+ left = anchor.left + anchor.width / 2 - floating.width / 2 + alignOffset
190
+ break
191
+ case 'end':
192
+ left = anchorRight - floating.width - alignOffset
193
+ break
194
+ }
195
+ } else {
196
+ left =
197
+ side === 'right'
198
+ ? anchorRight + offset
199
+ : anchor.left - floating.width - offset
200
+ switch (align) {
201
+ case 'start':
202
+ top = anchor.top + alignOffset
203
+ break
204
+ case 'center':
205
+ top = anchor.top + anchor.height / 2 - floating.height / 2 + alignOffset
206
+ break
207
+ case 'end':
208
+ top = anchorBottom - floating.height - alignOffset
209
+ break
210
+ }
211
+ }
212
+
213
+ if (clampToViewport) {
214
+ // `max` first: when the element is wider than the viewport the upper
215
+ // bound is below the gutter, and the gutter wins.
216
+ left = clampNumber(left, gutter, viewport.width - floating.width - gutter)
217
+ top = clampNumber(top, gutter, viewport.height - floating.height - gutter)
218
+ }
219
+
220
+ return {
221
+ left: Math.round(left),
222
+ top: Math.round(top),
223
+ placement: { side, align },
224
+ }
225
+ }
226
+
227
+ export interface UseAnchoredPositionOptions extends AnchoredPositionOptions {
228
+ /** Element to anchor to. Ignored while `anchorRect` is non-null. */
229
+ anchorRef?: RefObject<Element | null>
230
+ /** Virtual anchor (a right-click point, a text caret). Wins over `anchorRef`. */
231
+ anchorRect?: AnchorRect | null
232
+ /** Measure and track while true. Default true. */
233
+ enabled?: boolean
234
+ }
235
+
236
+ export interface UseAnchoredPositionResult<T extends HTMLElement> {
237
+ /** Attach to the floating element. */
238
+ ref: (node: T | null) => void
239
+ /** The same node as a ref object — for `contains()` checks. */
240
+ floatingRef: RefObject<T | null>
241
+ /** `position: fixed` + coordinates; hidden and parked off-screen until measured. */
242
+ style: CSSProperties
243
+ /** Side / align actually used, or null until measured. */
244
+ placement: AnchorPlacement | null
245
+ /** True once real coordinates are applied. Gate entry motion on this. */
246
+ positioned: boolean
247
+ /** Re-measure now (after content you know changed size, for example). */
248
+ update: () => void
249
+ }
250
+
251
+ const PARKED_STYLE: CSSProperties = {
252
+ position: 'fixed',
253
+ // Parked far off-screen, not at (0, 0): if a renderer paints the
254
+ // unpositioned frame despite `visibility: hidden`, nothing flashes at the
255
+ // viewport's top-left corner.
256
+ left: -99999,
257
+ top: -99999,
258
+ visibility: 'hidden',
259
+ opacity: 0,
260
+ pointerEvents: 'none',
261
+ }
262
+
263
+ function viewportSize(): AnchorSize {
264
+ // `clientWidth` excludes a vertical scrollbar, which `innerWidth` includes;
265
+ // an end-aligned menu would otherwise sit under the scrollbar. jsdom
266
+ // reports 0 for clientWidth, hence the fallback.
267
+ const root = document.documentElement
268
+ return {
269
+ width: root.clientWidth || window.innerWidth,
270
+ height: root.clientHeight || window.innerHeight,
271
+ }
272
+ }
273
+
274
+ function samePosition(a: AnchoredPosition | null, b: AnchoredPosition): boolean {
275
+ return (
276
+ a !== null &&
277
+ a.left === b.left &&
278
+ a.top === b.top &&
279
+ a.placement.side === b.placement.side &&
280
+ a.placement.align === b.placement.align
281
+ )
282
+ }
283
+
284
+ export function useAnchoredPosition<T extends HTMLElement = HTMLElement>(
285
+ options: UseAnchoredPositionOptions,
286
+ ): UseAnchoredPositionResult<T> {
287
+ const {
288
+ anchorRef,
289
+ anchorRect = null,
290
+ enabled = true,
291
+ side = 'bottom',
292
+ align = 'start',
293
+ offset = 6,
294
+ alignOffset = 0,
295
+ flip = true,
296
+ clampToViewport = true,
297
+ gutter = 8,
298
+ } = options
299
+
300
+ // The floating node lives in state as well as a ref so the measuring
301
+ // effect re-runs when the element mounts — a RefObject alone cannot
302
+ // signal that.
303
+ const floatingRef = useRef<T | null>(null)
304
+ const [floating, setFloating] = useState<T | null>(null)
305
+ const ref = useCallback((node: T | null) => {
306
+ floatingRef.current = node
307
+ setFloating(node)
308
+ }, [])
309
+
310
+ const [position, setPosition] = useState<AnchoredPosition | null>(null)
311
+
312
+ // Latest virtual rect, read at measure time. Keyed by value (not object
313
+ // identity) so a caller passing a fresh literal each render does not
314
+ // re-subscribe the listeners.
315
+ const anchorRectRef = useRef<AnchorRect | null>(anchorRect)
316
+ useLayoutEffect(() => {
317
+ anchorRectRef.current = anchorRect
318
+ })
319
+ const rectKey = anchorRect
320
+ ? `${anchorRect.left}|${anchorRect.top}|${anchorRect.width}|${anchorRect.height}`
321
+ : ''
322
+
323
+ const measure = useCallback(() => {
324
+ const node = floatingRef.current
325
+ if (!node) return
326
+ const rect =
327
+ anchorRectRef.current ?? anchorRef?.current?.getBoundingClientRect() ?? null
328
+ if (!rect) return
329
+ // offsetWidth/Height are layout sizes, untouched by the entry animation's
330
+ // transform. getBoundingClientRect() would read a 0.95-scaled box mid-
331
+ // `.ds-enter-pop` and shift an end-aligned menu by 5% of its width.
332
+ const next = computeAnchoredPosition(
333
+ rect,
334
+ { width: node.offsetWidth, height: node.offsetHeight },
335
+ viewportSize(),
336
+ { side, align, offset, alignOffset, flip, clampToViewport, gutter },
337
+ )
338
+ setPosition((prev) => (samePosition(prev, next) ? prev : next))
339
+ }, [anchorRef, side, align, offset, alignOffset, flip, clampToViewport, gutter])
340
+
341
+ useLayoutEffect(() => {
342
+ if (!enabled || !floating) {
343
+ setPosition((prev) => (prev === null ? prev : null))
344
+ return
345
+ }
346
+ measure()
347
+ window.addEventListener('resize', measure)
348
+ // Capture phase: scrolls inside nested containers do not bubble, and a
349
+ // menu anchored inside one must follow its trigger.
350
+ window.addEventListener('scroll', measure, true)
351
+ let observer: ResizeObserver | null = null
352
+ if (typeof ResizeObserver !== 'undefined') {
353
+ observer = new ResizeObserver(() => measure())
354
+ observer.observe(floating)
355
+ const anchorEl = anchorRef?.current
356
+ if (anchorEl) observer.observe(anchorEl)
357
+ }
358
+ return () => {
359
+ window.removeEventListener('resize', measure)
360
+ window.removeEventListener('scroll', measure, true)
361
+ observer?.disconnect()
362
+ }
363
+ }, [enabled, floating, measure, anchorRef, rectKey])
364
+
365
+ const positioned = position !== null
366
+ const style: CSSProperties = positioned
367
+ ? { position: 'fixed', left: position.left, top: position.top }
368
+ : PARKED_STYLE
369
+
370
+ return {
371
+ ref,
372
+ floatingRef,
373
+ style,
374
+ placement: position?.placement ?? null,
375
+ positioned,
376
+ update: measure,
377
+ }
378
+ }
379
+
380
+ export type OutsideClickTarget = RefObject<Element | null> | Element | null | undefined
381
+
382
+ export interface UseOutsideClickOptions {
383
+ /** Default true. */
384
+ enabled?: boolean
385
+ /**
386
+ * This surface's dismiss layer. When given, a pointerdown inside any layer
387
+ * stacked ABOVE it (a Select opened from inside a Popover) is treated as
388
+ * inside, not outside.
389
+ */
390
+ layer?: LayerHandle
391
+ }
392
+
393
+ /**
394
+ * Calls `onOutside` on a `pointerdown` whose target is inside none of
395
+ * `targets`. Handler and targets are read through refs, so inline arrays
396
+ * and arrows do not re-subscribe the listener.
397
+ */
398
+ export function useOutsideClick(
399
+ targets: ReadonlyArray<OutsideClickTarget>,
400
+ onOutside: (event: PointerEvent) => void,
401
+ options: UseOutsideClickOptions = {},
402
+ ): void {
403
+ const { enabled = true, layer } = options
404
+
405
+ const targetsRef = useRef(targets)
406
+ const handlerRef = useRef(onOutside)
407
+ useEffect(() => {
408
+ targetsRef.current = targets
409
+ handlerRef.current = onOutside
410
+ })
411
+
412
+ useEffect(() => {
413
+ if (!enabled) return
414
+ const onPointerDown = (event: PointerEvent) => {
415
+ const target = event.target
416
+ if (!(target instanceof Node)) return
417
+ for (const candidate of targetsRef.current) {
418
+ const el = candidate instanceof Node ? candidate : candidate?.current
419
+ if (el?.contains(target)) return
420
+ }
421
+ if (layer?.containsInLayerAbove(target)) return
422
+ handlerRef.current(event)
423
+ }
424
+ document.addEventListener('pointerdown', onPointerDown)
425
+ return () => document.removeEventListener('pointerdown', onPointerDown)
426
+ }, [enabled, layer])
427
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Tabbable-element query shared by the focus-managing surfaces (Modal's
3
+ * trap, Popover's focus-on-open). Extracted from modal.tsx.
4
+ *
5
+ * Deliberately attribute-only — no geometry check. `offsetWidth` /
6
+ * `getClientRects()` are always zero in jsdom, so a visibility filter would
7
+ * empty this list under test and silently disable the trap in exactly the
8
+ * environment that verifies it.
9
+ */
10
+
11
+ const FOCUSABLE_SELECTOR = [
12
+ 'a[href]',
13
+ 'area[href]',
14
+ 'button:not([disabled])',
15
+ 'input:not([disabled]):not([type="hidden"])',
16
+ 'select:not([disabled])',
17
+ 'textarea:not([disabled])',
18
+ 'iframe',
19
+ 'summary',
20
+ '[contenteditable="true"]',
21
+ '[tabindex]',
22
+ ].join(',')
23
+
24
+ export function tabbablesWithin(root: HTMLElement): HTMLElement[] {
25
+ return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
26
+ (el) =>
27
+ el.tabIndex >= 0 &&
28
+ !el.hasAttribute('inert') &&
29
+ el.getAttribute('aria-hidden') !== 'true' &&
30
+ !el.closest('[hidden]'),
31
+ )
32
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Dismiss-layer stack — the ONE "topmost layer wins" registry shared by
3
+ * Modal, DropdownMenu and Popover (and, next, ContextMenu / Select /
4
+ * Combobox / Tooltip / CommandPalette).
5
+ *
6
+ * Extracted from modal.tsx's module-level `openPanels` array so every
7
+ * dismissable surface participates in the same order. Before this, a
8
+ * DropdownMenu opened from inside a Modal answered Escape at the same time
9
+ * the Modal did — two document listeners, one keypress, both closed. Now a
10
+ * single document `keydown` listener dispatches Escape to the top of the
11
+ * stack ONLY, then stops propagation so window-level handlers (the chat
12
+ * conversation drawer's Escape, lens hotkey maps) do not treat a consumed
13
+ * Escape as theirs — which is what DropdownMenu's own listener already did.
14
+ *
15
+ * Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
16
+ * every picker, sheet and palette needs one dismiss order). First consumer
17
+ * is the workspace app: Modal (51 files), DropdownMenu (137 call sites) and
18
+ * Popover all register here.
19
+ *
20
+ * Two kinds. A `modal` layer also owns a focus trap, and that trap must not
21
+ * switch off because a `popover` opened above it — so Modal asks
22
+ * `isTopOfKind()` for Tab handling while Escape always goes to `isTop()`.
23
+ * Order is push order, which under React is effect order: a surface that
24
+ * mounts or opens later sits above one that opened earlier.
25
+ *
26
+ * Usage (a dismissable surface):
27
+ *
28
+ * const layer = useLayer({
29
+ * enabled: open,
30
+ * kind: 'popover',
31
+ * elementRef: contentRef,
32
+ * onEscape: () => setOpen(false),
33
+ * })
34
+ * useOutsideClick([triggerRef, contentRef], () => setOpen(false), {
35
+ * enabled: open,
36
+ * layer, // clicks inside a layer stacked ABOVE this one are not "outside"
37
+ * })
38
+ *
39
+ * `useEscapeKey(handler, { enabled })` is the one-liner for anything that
40
+ * only needs Escape.
41
+ */
42
+
43
+ import { useEffect, useMemo, useRef, type RefObject } from 'react'
44
+
45
+ export type LayerKind = 'modal' | 'popover'
46
+
47
+ export interface LayerHandle {
48
+ /** True while no layer of ANY kind sits above this one. */
49
+ isTop(): boolean
50
+ /** True while no layer of the SAME kind sits above this one. */
51
+ isTopOfKind(): boolean
52
+ /**
53
+ * True when `node` is inside the element of a layer stacked above this
54
+ * one — a nested Select's listbox, a Combobox inside a Popover. Outside-
55
+ * click handlers treat those as inside.
56
+ */
57
+ containsInLayerAbove(node: Node): boolean
58
+ }
59
+
60
+ interface LayerRecord {
61
+ readonly kind: LayerKind
62
+ readonly element: () => HTMLElement | null
63
+ readonly onEscape: (event: KeyboardEvent) => void
64
+ }
65
+
66
+ const stack: LayerRecord[] = []
67
+ let listening = false
68
+
69
+ function onDocumentKeyDown(event: KeyboardEvent): void {
70
+ if (event.key !== 'Escape') return
71
+ const top = stack[stack.length - 1]
72
+ if (!top) return
73
+ // Consumed here. Window-level listeners (hotkey maps, drawers) must not
74
+ // see an Escape that closed a layer — same contract DropdownMenu's own
75
+ // listener had before the stack existed.
76
+ event.stopPropagation()
77
+ top.onEscape(event)
78
+ }
79
+
80
+ function pushLayer(record: LayerRecord): () => void {
81
+ stack.push(record)
82
+ if (!listening) {
83
+ document.addEventListener('keydown', onDocumentKeyDown)
84
+ listening = true
85
+ }
86
+ return () => {
87
+ const at = stack.lastIndexOf(record)
88
+ if (at !== -1) stack.splice(at, 1)
89
+ if (stack.length === 0 && listening) {
90
+ document.removeEventListener('keydown', onDocumentKeyDown)
91
+ listening = false
92
+ }
93
+ }
94
+ }
95
+
96
+ export interface UseLayerOptions {
97
+ /** Register while true; pop when it turns false or the owner unmounts. */
98
+ enabled: boolean
99
+ kind: LayerKind
100
+ /**
101
+ * The surface's root element. Read lazily, so a ref that is filled in
102
+ * after the layer registers (portal content) still resolves.
103
+ */
104
+ elementRef?: RefObject<HTMLElement | null>
105
+ /** Called when this layer is topmost and Escape is pressed. */
106
+ onEscape: (event: KeyboardEvent) => void
107
+ }
108
+
109
+ /**
110
+ * Register a dismiss layer for as long as `enabled` holds. `onEscape` is
111
+ * read through a ref, so an inline arrow does not re-register the layer
112
+ * (and does not reorder it) on every render.
113
+ */
114
+ export function useLayer(options: UseLayerOptions): LayerHandle {
115
+ const { enabled, kind, elementRef, onEscape } = options
116
+
117
+ const onEscapeRef = useRef(onEscape)
118
+ useEffect(() => {
119
+ onEscapeRef.current = onEscape
120
+ })
121
+
122
+ const recordRef = useRef<LayerRecord | null>(null)
123
+
124
+ useEffect(() => {
125
+ if (!enabled) return
126
+ const record: LayerRecord = {
127
+ kind,
128
+ element: () => elementRef?.current ?? null,
129
+ onEscape: (event) => onEscapeRef.current(event),
130
+ }
131
+ recordRef.current = record
132
+ const pop = pushLayer(record)
133
+ return () => {
134
+ pop()
135
+ if (recordRef.current === record) recordRef.current = null
136
+ }
137
+ }, [enabled, kind, elementRef])
138
+
139
+ return useMemo<LayerHandle>(
140
+ () => ({
141
+ isTop: () => {
142
+ const record = recordRef.current
143
+ return record !== null && stack[stack.length - 1] === record
144
+ },
145
+ isTopOfKind: () => {
146
+ const record = recordRef.current
147
+ if (!record) return false
148
+ for (let i = stack.length - 1; i >= 0; i--) {
149
+ const layer = stack[i]
150
+ if (layer === record) return true
151
+ if (layer?.kind === record.kind) return false
152
+ }
153
+ return false
154
+ },
155
+ containsInLayerAbove: (node) => {
156
+ const record = recordRef.current
157
+ if (!record) return false
158
+ const at = stack.indexOf(record)
159
+ if (at === -1) return false
160
+ for (let i = at + 1; i < stack.length; i++) {
161
+ if (stack[i]?.element()?.contains(node)) return true
162
+ }
163
+ return false
164
+ },
165
+ }),
166
+ [],
167
+ )
168
+ }
169
+
170
+ export interface UseEscapeKeyOptions {
171
+ /** Default true. */
172
+ enabled?: boolean
173
+ /** Default `popover`. */
174
+ kind?: LayerKind
175
+ }
176
+
177
+ /**
178
+ * Escape-only participant in the layer stack. Fires `onEscape` only while
179
+ * this is the topmost layer; a layer that opens later takes over until it
180
+ * closes.
181
+ */
182
+ export function useEscapeKey(
183
+ onEscape: (event: KeyboardEvent) => void,
184
+ options: UseEscapeKeyOptions = {},
185
+ ): LayerHandle {
186
+ const { enabled = true, kind = 'popover' } = options
187
+ return useLayer({ enabled, kind, onEscape })
188
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ref plumbing for primitives that must hand one DOM node to several
3
+ * owners — the caller's forwarded ref, the component's own ref, and a
4
+ * positioning hook's callback ref.
5
+ *
6
+ * `assignRef` writes a node into any React ref shape. `composeRefs` returns
7
+ * ONE callback ref that fans out to all of them; memoize the result
8
+ * (`useMemo` on the inputs) so React does not detach/re-attach on every
9
+ * render.
10
+ */
11
+
12
+ import type { Ref, RefCallback } from 'react'
13
+
14
+ export function assignRef<T>(ref: Ref<T> | undefined, node: T | null): void {
15
+ if (!ref) return
16
+ if (typeof ref === 'function') {
17
+ // React 19 callback refs may return a cleanup; we call with `null` on
18
+ // detach instead, which every consumer of this package already handles.
19
+ ref(node)
20
+ return
21
+ }
22
+ ref.current = node
23
+ }
24
+
25
+ export function composeRefs<T>(
26
+ ...refs: ReadonlyArray<Ref<T> | undefined>
27
+ ): RefCallback<T> {
28
+ return (node) => {
29
+ for (const ref of refs) assignRef(ref, node)
30
+ }
31
+ }