@lovett/ui 0.0.10 → 0.1.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.
Files changed (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -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
+ }