@lovett/ui 0.0.9 → 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.
Files changed (55) hide show
  1. package/dist/index.d.ts +823 -135
  2. package/dist/index.js +2048 -358
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +44 -2
  5. package/dist/theme-v2.css +228 -0
  6. package/dist/tokens.css +123 -8
  7. package/package.json +1 -1
  8. package/src/__tests__/anchor.test.tsx +422 -0
  9. package/src/__tests__/combobox.test.tsx +677 -0
  10. package/src/__tests__/dropdown-menu.test.tsx +418 -0
  11. package/src/__tests__/helpers/geometry.ts +58 -0
  12. package/src/__tests__/layer-stack.test.tsx +228 -0
  13. package/src/__tests__/modal.test.tsx +180 -6
  14. package/src/__tests__/popover.test.tsx +460 -0
  15. package/src/__tests__/select.test.tsx +543 -0
  16. package/src/__tests__/tooltip.test.tsx +355 -0
  17. package/src/calculator-shell-v2.tsx +19 -39
  18. package/src/code-block.tsx +15 -26
  19. package/src/combobox.tsx +796 -0
  20. package/src/dropdown-menu.tsx +142 -152
  21. package/src/icons/brand.tsx +81 -2
  22. package/src/index.ts +111 -0
  23. package/src/lib/anchor.ts +427 -0
  24. package/src/lib/focus.ts +32 -0
  25. package/src/lib/layer-stack.ts +188 -0
  26. package/src/lib/refs.ts +31 -0
  27. package/src/metric-card.tsx +57 -22
  28. package/src/modal.tsx +149 -9
  29. package/src/page-shell.tsx +91 -2
  30. package/src/popover.tsx +407 -0
  31. package/src/segmented-pill.tsx +33 -10
  32. package/src/select.tsx +646 -0
  33. package/src/stat-row.tsx +108 -70
  34. package/src/styles.css +44 -2
  35. package/src/theme-v2.css +7 -245
  36. package/src/tokens.css +123 -8
  37. package/src/tooltip.tsx +297 -0
  38. package/src/react-syntax-highlighter-prism.d.ts +0 -34
  39. package/src/v2/README.md +0 -208
  40. package/src/v2/__demo__/showcase.tsx +0 -1045
  41. package/src/v2/action.tsx +0 -91
  42. package/src/v2/callout.tsx +0 -76
  43. package/src/v2/document-section.tsx +0 -82
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +0 -113
  46. package/src/v2/icons.tsx +0 -165
  47. package/src/v2/index.ts +0 -147
  48. package/src/v2/layout.tsx +0 -293
  49. package/src/v2/progress-track.tsx +0 -89
  50. package/src/v2/stat-tile.tsx +0 -129
  51. package/src/v2/states.tsx +0 -271
  52. package/src/v2/status-pill.tsx +0 -74
  53. package/src/v2/theme.css +0 -1861
  54. package/src/v2/timeline.tsx +0 -81
  55. package/src/v2/tokens.ts +0 -228
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export { default as MicrosoftLogo, type MicrosoftLogoProps } from './microsoft-l
28
28
  export {
29
29
  BrandMeta,
30
30
  BrandFacebook,
31
+ BrandIcon,
32
+ hasBrandIcon,
31
33
  BrandInstagram,
32
34
  BrandMessenger,
33
35
  BrandLinkedIn,
@@ -45,7 +47,10 @@ export {
45
47
  AppScroll,
46
48
  PageHeaderHost,
47
49
  PageHeaderSlotProvider,
50
+ HomeCrumbLink,
51
+ useBreadcrumbHome,
48
52
  type Crumb,
53
+ type HomeCrumb,
49
54
  } from './page-shell'
50
55
  export { Toaster, toast } from './toast'
51
56
 
@@ -167,6 +172,112 @@ export {
167
172
  type EditingCell,
168
173
  } from './data-grid'
169
174
 
175
+ // Anchored-positioning kit + dismiss-layer stack — promoted per ADR-0030
176
+ // Decision G (meta-ads-audit-dashboard task manager: its pickers, sheets
177
+ // and palette need one positioning routine and one Escape order). The
178
+ // workspace app is the other consumer: DropdownMenu (137 call sites),
179
+ // Modal (51 files) and Popover are rebuilt on these in the same change.
180
+ // `useAnchoredPosition` measures / flips / clamps against an element ref
181
+ // OR a virtual rect (ContextMenu's right-click point); `useOutsideClick`
182
+ // is layer-aware; `useLayer` / `useEscapeKey` are the topmost-wins stack
183
+ // extracted from Modal's `openPanels`. Select / Combobox / Tooltip /
184
+ // ContextMenu build on them next; FilterDropdown / FolderTreePicker /
185
+ // TagChipInput still carry local copies of the positioning block
186
+ // (follow-up).
187
+ export {
188
+ useAnchoredPosition,
189
+ computeAnchoredPosition,
190
+ useOutsideClick,
191
+ type AnchorSide,
192
+ type AnchorAlign,
193
+ type AnchorRect,
194
+ type AnchorSize,
195
+ type AnchorPlacement,
196
+ type AnchoredPosition,
197
+ type AnchoredPositionOptions,
198
+ type UseAnchoredPositionOptions,
199
+ type UseAnchoredPositionResult,
200
+ type OutsideClickTarget,
201
+ type UseOutsideClickOptions,
202
+ } from './lib/anchor'
203
+ export {
204
+ useLayer,
205
+ useEscapeKey,
206
+ type LayerKind,
207
+ type LayerHandle,
208
+ type UseLayerOptions,
209
+ type UseEscapeKeyOptions,
210
+ } from './lib/layer-stack'
211
+
212
+ // Popover — anchored, non-modal surface: portal to body, `.ds-enter-pop`
213
+ // once positioned, focus into the content on open and back to the trigger
214
+ // on close, Escape closes the topmost layer only, outside-click closes.
215
+ // Compound: Popover + Popover.Trigger (`asChild` to wrap a <Button> or a
216
+ // cell) + Popover.Content (`role="dialog"` or `"none"`). Promoted per
217
+ // ADR-0030 Decision G; the workspace app's three hand-rolled popovers
218
+ // (sidebar user menu, group-settings colour / icon pickers, calculator
219
+ // AssumptionsPopover) are the migration targets. DropdownMenu shares its
220
+ // surface chrome (`POPOVER_SURFACE_STYLE`) and stack, but stays a sibling
221
+ // because a menu does not move focus into itself on open.
222
+ export {
223
+ Popover,
224
+ POPOVER_SURFACE_STYLE,
225
+ type PopoverProps,
226
+ type PopoverTriggerProps,
227
+ type PopoverContentProps,
228
+ } from './popover'
229
+
230
+ // Select — single-value picker: an `.input-shell` trigger (sm / md, sits
231
+ // flush beside <Input>) that opens a real listbox (APG select-only
232
+ // combobox: role=combobox trigger, aria-activedescendant, role=listbox /
233
+ // option, arrow / Home / End / Enter / Space / type-ahead, disabled
234
+ // options, optional icon + description). Promoted per ADR-0030 Decision G
235
+ // — the meta-ads-audit-dashboard task manager's status / priority / role
236
+ // pickers; its Users page hand-rolled a native <select> with a comment
237
+ // saying the package ships none. The workspace app is the second consumer
238
+ // (26 native <select>s, products/product-form.tsx says the same). Built on
239
+ // useAnchoredPosition + the layer stack; shares the popover surface chrome.
240
+ // `ListboxOption` / `LISTBOX_CLASS` are the row + popup chrome Combobox
241
+ // reuses, exported for the same reason POPOVER_SURFACE_STYLE is.
242
+ export {
243
+ Select,
244
+ ListboxOption,
245
+ LISTBOX_CLASS,
246
+ type SelectProps,
247
+ type SelectOption,
248
+ type SelectSize,
249
+ type ListboxOptionProps,
250
+ } from './select'
251
+
252
+ // Combobox — text input + filtered listbox (APG editable combobox with list
253
+ // autocomplete), single or multi-select with chips, optional grouped options
254
+ // with headings, a per-option live `count` slot and a `renderOption` slot.
255
+ // Async-friendly: `shouldFilter={false}` + `loading` + `onInputValueChange`;
256
+ // it never fetches. Promoted per ADR-0030 Decision G — the task manager's
257
+ // row / card / context-menu / palette pickers are "popover + search + live
258
+ // counts". Second consumer is the workspace app
259
+ // (sem-spec/location-autocomplete.tsx hand-rolls the role; TagChipInput
260
+ // carries the chip model this generalises).
261
+ export {
262
+ Combobox,
263
+ type ComboboxProps,
264
+ type ComboboxSingleProps,
265
+ type ComboboxMultipleProps,
266
+ type ComboboxOption,
267
+ type ComboboxGroup,
268
+ type ComboboxOptionState,
269
+ type ComboboxSize,
270
+ } from './combobox'
271
+
272
+ // Tooltip — hover (300 ms) / focus (immediate) triggered role=tooltip with
273
+ // aria-describedby wiring, Escape-dismissable through the layer stack, never
274
+ // focusable, pointer-events none, token-only inline surface like Kbd.
275
+ // Promoted per ADR-0030 Decision G (task manager: truncated cells, icon-only
276
+ // row actions, timestamps). Second consumer is the workspace app, which
277
+ // hand-rolls two today (tools/_shared/calc/info-tip.tsx,
278
+ // components/ui/sidebar/rail-tooltip.tsx).
279
+ export { Tooltip, type TooltipProps, type TooltipTriggerProps } from './tooltip'
280
+
170
281
  // DropdownButton — Button-styled trigger + rich popover menu. Composed
171
282
  // over <DropdownMenu> primitives. First consumer: Keywords dashboard's
172
283
  // "New Keyword Plan" CTA (Set/Group picker). Reusable across other
@@ -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
+ }