@hanzogui/popper 2.0.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.
package/src/Popper.tsx ADDED
@@ -0,0 +1,1022 @@
1
+ // adapted from radix-ui popper
2
+ import { flushSync } from 'react-dom'
3
+ import { useComposedRefs } from '@hanzogui/compose-refs'
4
+ import { isWeb, useIsomorphicLayoutEffect } from '@hanzogui/constants'
5
+ import type { SizeTokens, GuiElement, ViewProps } from '@hanzogui/core'
6
+ import {
7
+ LayoutMeasurementController,
8
+ View as GuiView,
9
+ createStyledContext,
10
+ getVariableValue,
11
+ registerLayoutNode,
12
+ styled,
13
+ } from '@hanzogui/core'
14
+ import type { PopupTriggerMap } from '@hanzogui/floating'
15
+ import { FloatingOverrideContext } from '@hanzogui/floating'
16
+ import type {
17
+ Coords,
18
+ Middleware,
19
+ OffsetOptions,
20
+ Placement,
21
+ ReferenceType,
22
+ Side,
23
+ SizeOptions,
24
+ Strategy,
25
+ UseFloatingReturn,
26
+ } from '@hanzogui/floating'
27
+ import {
28
+ arrow,
29
+ flip,
30
+ getOverflowAncestors,
31
+ offset as offsetFn,
32
+ platform,
33
+ shift,
34
+ size as sizeMiddleware,
35
+ useFloating,
36
+ } from '@hanzogui/floating'
37
+ import { getSpace } from '@hanzogui/get-token'
38
+ import type { SizableStackProps, YStackProps } from '@hanzogui/stacks'
39
+ import { YStack } from '@hanzogui/stacks'
40
+ import { startTransition } from '@hanzogui/start-transition'
41
+ import * as React from 'react'
42
+ import { Keyboard, useWindowDimensions } from 'react-native'
43
+
44
+ type ShiftProps = typeof shift extends (options: infer Opts) => void ? Opts : never
45
+ type FlipProps = typeof flip extends (options: infer Opts) => void ? Opts : never
46
+
47
+ /* -------------------------------------------------------------------------------------------------
48
+ * Popper
49
+ * -----------------------------------------------------------------------------------------------*/
50
+
51
+ export type PopperContextShared = {
52
+ open: boolean
53
+ size?: SizeTokens
54
+ hasFloating: boolean
55
+ arrowStyle?: Partial<Coords> & {
56
+ centerOffset: number
57
+ }
58
+ placement?: Placement
59
+ arrowRef: any
60
+ onArrowSize?: (val: number) => void
61
+ transformOrigin?: { x: string; y: string }
62
+ }
63
+
64
+ export type PopperContextValue = UseFloatingReturn & PopperContextShared
65
+
66
+ export const PopperContextFast = createStyledContext<PopperContextValue>(
67
+ // since we always provide this we can avoid setting here
68
+ {} as PopperContextValue,
69
+ 'Popper__'
70
+ )
71
+
72
+ export const PopperPositionContext = createStyledContext
73
+
74
+ export const { useStyledContext: usePopperContext, Provider: PopperProviderFast } =
75
+ PopperContextFast
76
+
77
+ export type PopperContextSlowValue = Pick<
78
+ UseFloatingReturn,
79
+ 'getReferenceProps' | 'update' | 'refs'
80
+ > & {
81
+ onHoverReference?: (event: any) => void
82
+ onLeaveReference?: () => void
83
+ triggerElements?: PopupTriggerMap
84
+ }
85
+
86
+ export const PopperContextSlow = createStyledContext<PopperContextSlowValue>(
87
+ // since we always provide this we can avoid setting here
88
+ {} as PopperContextSlowValue,
89
+ 'PopperSlow__'
90
+ )
91
+
92
+ export const { useStyledContext: usePopperContextSlow, Provider: PopperProviderSlow } =
93
+ PopperContextSlow
94
+
95
+ // handles both slow and fast:
96
+ export const PopperProvider = ({
97
+ scope,
98
+ children,
99
+ ...context
100
+ }: PopperContextValue & { scope?: string; children?: React.ReactNode }) => {
101
+ // single ref holds all unstable functions — updated every render so the
102
+ // stable wrappers below always forward to the latest version
103
+ const fns = React.useRef(context)
104
+ fns.current = context
105
+
106
+ // stable wrappers that never change identity — objectIdentityKey in
107
+ // createStyledContext produces the same key across renders, so PopperAnchor
108
+ // instances never re-render from context changes (only from parent re-renders)
109
+ const [slowContext] = React.useState(
110
+ (): PopperContextSlowValue => ({
111
+ refs: context.refs,
112
+ triggerElements: context.triggerElements,
113
+ update(...a: []) {
114
+ fns.current.update(...a)
115
+ },
116
+ getReferenceProps(p?: any) {
117
+ return fns.current.getReferenceProps?.(p)
118
+ },
119
+ onHoverReference(e?: any) {
120
+ ;(fns.current as any).onHoverReference?.(e)
121
+ },
122
+ onLeaveReference() {
123
+ ;(fns.current as any).onLeaveReference?.()
124
+ },
125
+ })
126
+ )
127
+
128
+ return (
129
+ <PopperProviderFast scope={scope} {...context}>
130
+ <PopperProviderSlow scope={scope} {...slowContext}>
131
+ {children}
132
+ </PopperProviderSlow>
133
+ </PopperProviderFast>
134
+ )
135
+ }
136
+
137
+ export type PopperProps = {
138
+ /**
139
+ * Popper is a component used by other components to create interfaces, so scope is required
140
+ * For example Popover uses it internally and sets a default "POPOVER_SCOPE".
141
+ */
142
+ scope?: string
143
+
144
+ /**
145
+ * Optional, will disable measuring updates when open is false for better performance
146
+ * */
147
+ open?: boolean
148
+
149
+ size?: SizeTokens
150
+ children?: React.ReactNode
151
+
152
+ /**
153
+ * Determine the preferred placement of the content in relation to the trigger
154
+ */
155
+ placement?: Placement
156
+
157
+ /**
158
+ * Shifts content horizontally to stay within viewport.
159
+ * Pass an object to override shift options (mainAxis, crossAxis, padding, etc).
160
+ * Defaults: { mainAxis: true, crossAxis: false, padding: 10 }
161
+ * @see https://floating-ui.com/docs/shift
162
+ */
163
+ stayInFrame?: ShiftProps | boolean
164
+
165
+ /**
166
+ * Allows content to switch sides when space is limited.
167
+ * @see https://floating-ui.com/docs/flip
168
+ */
169
+ allowFlip?: FlipProps | boolean
170
+
171
+ /**
172
+ * Resizes the content to fix inside the screen when space is limited
173
+ * @see https://floating-ui.com/docs/size
174
+ */
175
+ resize?: boolean | Omit<SizeOptions, 'apply'>
176
+
177
+ /**
178
+ * Choose between absolute or fixed positioning
179
+ */
180
+ strategy?: Strategy
181
+
182
+ /**
183
+ * Move the content away from the trigger
184
+ * @see https://floating-ui.com/docs/offset
185
+ */
186
+ offset?: OffsetOptions
187
+
188
+ disableRTL?: boolean
189
+
190
+ passThrough?: boolean
191
+ }
192
+
193
+ const checkFloating =
194
+ process.env.HANZO_GUI_TARGET === 'native'
195
+ ? {
196
+ name: 'checkFloating',
197
+ fn(data: any) {
198
+ return {
199
+ data: {
200
+ hasFloating: !!data.rects.floating.width,
201
+ },
202
+ }
203
+ },
204
+ }
205
+ : undefined
206
+
207
+ export type PopperSetupOptions = {
208
+ disableRTL?: boolean
209
+ }
210
+
211
+ const setupOptions: PopperSetupOptions = {}
212
+
213
+ export function setupPopper(options?: PopperSetupOptions) {
214
+ Object.assign(setupOptions, options)
215
+ }
216
+
217
+ // forked from radix-ui 👇
218
+ // https://github.com/radix-ui/primitives/blob/1910a8c91c5927e58b8fca3aeaa31411f32fee7c/packages/react/popper/src/Popper.tsx#L359-L399
219
+ function getSideAndAlignFromPlacement(placement: Placement) {
220
+ const [side, align = 'center'] = placement.split('-')
221
+ return [side as Side, align as 'center' | 'start' | 'end'] as const
222
+ }
223
+
224
+ const transformOriginMiddleware = (options: {
225
+ arrowWidth: number
226
+ arrowHeight: number
227
+ }): Middleware => ({
228
+ name: 'transformOrigin',
229
+ options,
230
+ fn(data) {
231
+ const { placement, rects, middlewareData } = data
232
+
233
+ const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0
234
+ const isArrowHidden = cannotCenterArrow
235
+ const arrowWidth = isArrowHidden ? 0 : options.arrowWidth
236
+ const arrowHeight = isArrowHidden ? 0 : options.arrowHeight
237
+
238
+ const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement)
239
+ const noArrowAlign = { start: '0%', center: '50%', end: '100%' }[placedAlign]
240
+
241
+ const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2
242
+ const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2
243
+
244
+ let x = ''
245
+ let y = ''
246
+
247
+ if (placedSide === 'bottom') {
248
+ x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
249
+ y = `${-arrowHeight}px`
250
+ } else if (placedSide === 'top') {
251
+ x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
252
+ y = `${rects.floating.height + arrowHeight}px`
253
+ } else if (placedSide === 'right') {
254
+ x = `${-arrowHeight}px`
255
+ y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
256
+ } else if (placedSide === 'left') {
257
+ x = `${rects.floating.width + arrowHeight}px`
258
+ y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
259
+ }
260
+
261
+ return { data: { x, y } }
262
+ },
263
+ })
264
+
265
+ // replaces floating-ui's autoUpdate with gui's batched IO measurement loop
266
+ // keeps scroll/resize listeners for immediate response, but replaces per-element
267
+ // ResizeObserver + IntersectionObserver with the shared layoutOnAnimationFrame loop
268
+ function guiAutoUpdate(
269
+ reference: ReferenceType,
270
+ floating: HTMLElement,
271
+ update: () => void
272
+ ): () => void {
273
+ // initial position
274
+ update()
275
+
276
+ // schedule a second update after layout/scroll events settle (e.g. focus-
277
+ // triggered scrolls that cause flip corrections)
278
+ let rafId = requestAnimationFrame(() => {
279
+ update()
280
+ rafId = 0
281
+ })
282
+
283
+ const cleanups: (() => void)[] = [
284
+ () => {
285
+ if (rafId) cancelAnimationFrame(rafId)
286
+ },
287
+ ]
288
+
289
+ // watch reference element via gui's IO measurement loop
290
+ // only watch reference, NOT floating — watching floating causes loops
291
+ // (computePosition sets position → rect changes → update → repeat)
292
+ if (reference instanceof HTMLElement) {
293
+ cleanups.push(registerLayoutNode(reference, update))
294
+ }
295
+
296
+ // scroll listeners for immediate response (only for real DOM elements)
297
+ const refAncestors = reference instanceof Element ? getOverflowAncestors(reference) : []
298
+ const ancestors = [...refAncestors, ...getOverflowAncestors(floating)]
299
+ const uniqueAncestors = [...new Set(ancestors)]
300
+ for (const ancestor of uniqueAncestors) {
301
+ ancestor.addEventListener('scroll', update, { passive: true })
302
+ }
303
+
304
+ // window resize
305
+ window.addEventListener('resize', update)
306
+
307
+ cleanups.push(() => {
308
+ for (const ancestor of uniqueAncestors) {
309
+ ancestor.removeEventListener('scroll', update)
310
+ }
311
+ window.removeEventListener('resize', update)
312
+ })
313
+
314
+ return () => cleanups.forEach((fn) => fn())
315
+ }
316
+
317
+ export function Popper(props: PopperProps) {
318
+ const {
319
+ children,
320
+ size,
321
+ strategy = 'absolute',
322
+ placement = 'bottom',
323
+ stayInFrame,
324
+ allowFlip,
325
+ offset,
326
+ disableRTL,
327
+ resize,
328
+ passThrough,
329
+ open,
330
+ scope,
331
+ } = props
332
+
333
+ const [arrowEl, setArrow] = React.useState<any>(null)
334
+ const [arrowSize, setArrowSize] = React.useState(0)
335
+ const offsetOptions = offset ?? arrowSize
336
+ const floatingStyle = React.useRef({})
337
+ const isOpen = passThrough ? false : (open ?? true)
338
+
339
+ // freeze middleware reference when closed so floating-ui's deepEqual trivially
340
+ // passes (same object) and skips computePosition on re-renders while closed.
341
+ // unlike swapping to [], this retains the last good middleware so cached
342
+ // position data (offset, arrow, transformOrigin) stays correct for reopen.
343
+ const middlewareRef = React.useRef<any[]>([])
344
+ if (isOpen) {
345
+ middlewareRef.current = [
346
+ // order matters: offset first, then flip, then shift, then arrow
347
+ typeof offsetOptions !== 'undefined' ? offsetFn(offsetOptions) : (null as any),
348
+ allowFlip ? flip(typeof allowFlip === 'boolean' ? {} : allowFlip) : (null as any),
349
+ // NOTE: shift's axis terminology is reversed vs flip/offset:
350
+ // for top/bottom placements: mainAxis = horizontal, crossAxis = vertical
351
+ // for left/right placements: mainAxis = vertical, crossAxis = horizontal
352
+ // default to horizontal shift only (mainAxis: true, crossAxis: false)
353
+ stayInFrame
354
+ ? shift({
355
+ padding: 10,
356
+ mainAxis: true,
357
+ crossAxis: false,
358
+ ...(typeof stayInFrame === 'object' ? stayInFrame : null),
359
+ })
360
+ : (null as any),
361
+ arrowEl ? arrow({ element: arrowEl }) : (null as any),
362
+ checkFloating,
363
+ process.env.HANZO_GUI_TARGET !== 'native' && resize
364
+ ? sizeMiddleware({
365
+ padding: typeof stayInFrame === 'object' ? stayInFrame.padding : 10,
366
+ apply({ availableHeight, availableWidth }) {
367
+ if (passThrough) {
368
+ return
369
+ }
370
+
371
+ Object.assign(floatingStyle.current, {
372
+ maxHeight: `${availableHeight}px`,
373
+ maxWidth: `${availableWidth}px`,
374
+ })
375
+ // we wrap PopperContent with one container stack so we need to account for it
376
+ const floatingChild = floating.refs.floating.current?.firstChild
377
+ if (floatingChild && floatingChild instanceof HTMLElement) {
378
+ Object.assign(floatingChild.style, floatingStyle.current)
379
+ }
380
+ },
381
+ ...(typeof resize === 'object' && resize),
382
+ })
383
+ : (null as any),
384
+ // Add size middleware for CSS custom properties (web only)
385
+ process.env.HANZO_GUI_TARGET !== 'native'
386
+ ? sizeMiddleware({
387
+ apply({ elements, rects, availableWidth, availableHeight }) {
388
+ const { width: anchorWidth, height: anchorHeight } = rects.reference
389
+ const contentStyle = elements.floating.style
390
+ contentStyle.setProperty(
391
+ '--gui-popper-available-width',
392
+ `${availableWidth}px`
393
+ )
394
+ contentStyle.setProperty(
395
+ '--gui-popper-available-height',
396
+ `${availableHeight}px`
397
+ )
398
+ contentStyle.setProperty(
399
+ '--gui-popper-anchor-width',
400
+ `${anchorWidth}px`
401
+ )
402
+ contentStyle.setProperty(
403
+ '--gui-popper-anchor-height',
404
+ `${anchorHeight}px`
405
+ )
406
+ },
407
+ })
408
+ : (null as any),
409
+ // Transform origin middleware (web only)
410
+ process.env.HANZO_GUI_TARGET !== 'native'
411
+ ? transformOriginMiddleware({
412
+ arrowHeight: arrowSize,
413
+ arrowWidth: arrowSize,
414
+ })
415
+ : (null as any),
416
+ ].filter(Boolean)
417
+ }
418
+
419
+ let floating = useFloating({
420
+ open: isOpen,
421
+ strategy,
422
+ placement,
423
+ sameScrollView: false, // this only takes effect on native
424
+ whileElementsMounted: !isOpen ? undefined : guiAutoUpdate,
425
+ platform:
426
+ (disableRTL ?? setupOptions.disableRTL)
427
+ ? {
428
+ ...platform,
429
+ isRTL(element) {
430
+ return false
431
+ },
432
+ }
433
+ : platform,
434
+ middleware: middlewareRef.current,
435
+ })
436
+
437
+ if (process.env.HANZO_GUI_TARGET !== 'native') {
438
+ // add our size middleware here
439
+ floating = React.useMemo(() => {
440
+ const og = floating.getFloatingProps
441
+ if (resize && og) {
442
+ floating.getFloatingProps = (props) => {
443
+ return og({
444
+ ...props,
445
+ style: {
446
+ ...props.style,
447
+ ...floatingStyle.current,
448
+ },
449
+ })
450
+ }
451
+ }
452
+ return floating
453
+ }, [floating, resize ? JSON.stringify(resize) : null])
454
+ }
455
+
456
+ const { middlewareData } = floating
457
+
458
+ if (process.env.HANZO_GUI_TARGET === 'native') {
459
+ // On Native there's no autoupdate so we call update() when necessary
460
+
461
+ // Subscribe to window dimensions (orientation, scale, etc...)
462
+ const dimensions = useWindowDimensions()
463
+
464
+ // Subscribe to keyboard state
465
+ const [keyboardOpen, setKeyboardOpen] = React.useState(false)
466
+ React.useEffect(() => {
467
+ const showSubscription = Keyboard.addListener('keyboardDidShow', () => {
468
+ startTransition(() => {
469
+ setKeyboardOpen(true)
470
+ })
471
+ })
472
+ const hideSubscription = Keyboard.addListener('keyboardDidHide', () => {
473
+ startTransition(() => {
474
+ setKeyboardOpen(false)
475
+ })
476
+ })
477
+
478
+ return () => {
479
+ showSubscription.remove()
480
+ hideSubscription.remove()
481
+ }
482
+ }, [])
483
+
484
+ useIsomorphicLayoutEffect(() => {
485
+ if (passThrough) return
486
+ floating.update()
487
+ }, [passThrough, dimensions, keyboardOpen])
488
+ }
489
+
490
+ const popperContext = React.useMemo(() => {
491
+ return {
492
+ size,
493
+ arrowRef: setArrow,
494
+ arrowStyle: middlewareData.arrow,
495
+ onArrowSize: setArrowSize,
496
+ hasFloating: middlewareData.checkFloating?.hasFloating,
497
+ transformOrigin: middlewareData.transformOrigin as
498
+ | { x: string; y: string }
499
+ | undefined,
500
+ open: !!open,
501
+ ...floating,
502
+ } satisfies PopperContextValue
503
+ }, [
504
+ open,
505
+ size,
506
+ floating,
507
+ JSON.stringify(middlewareData.arrow || null),
508
+ JSON.stringify(middlewareData.transformOrigin || null),
509
+ ])
510
+
511
+ return (
512
+ <PopperProvider scope={scope} {...popperContext}>
513
+ {/* reset FloatingOverrideContext so it doesn't leak into nested Poppers —
514
+ each Popper consumes the override for its own useFloating, children
515
+ should not inherit it (e.g. a Menu inside a Tooltip's tree) */}
516
+ <FloatingOverrideContext.Provider value={null}>
517
+ {children}
518
+ </FloatingOverrideContext.Provider>
519
+ </PopperProvider>
520
+ )
521
+ }
522
+
523
+ /* -------------------------------------------------------------------------------------------------
524
+ * PopperAnchor
525
+ * -----------------------------------------------------------------------------------------------*/
526
+
527
+ type PopperAnchorRef = GuiElement
528
+
529
+ export type PopperAnchorExtraProps = {
530
+ virtualRef?: React.RefObject<any>
531
+ scope?: string
532
+ }
533
+ export type PopperAnchorProps = YStackProps
534
+
535
+ export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
536
+ function PopperAnchor(props, forwardedRef) {
537
+ const { virtualRef, scope, ...rest } = props
538
+ const context = usePopperContextSlow(scope)
539
+ const { getReferenceProps, refs, update } = context
540
+ const ref = React.useRef<PopperAnchorRef>(null)
541
+ const triggerId = React.useId()
542
+
543
+ // register this trigger element with the shared trigger map
544
+ // so useHover can detect cursor moves between sibling triggers
545
+ React.useEffect(() => {
546
+ if (!scope || !context.triggerElements || !ref.current) return
547
+ if (!(ref.current instanceof Element)) return
548
+ const el = ref.current as Element
549
+ context.triggerElements.add(triggerId, el)
550
+ return () => {
551
+ context.triggerElements?.delete(triggerId)
552
+ }
553
+ }, [scope, triggerId, context.triggerElements])
554
+
555
+ React.useEffect(() => {
556
+ if (virtualRef) {
557
+ refs.setReference(virtualRef.current)
558
+ // recompute position after setting virtual reference
559
+ update()
560
+ }
561
+ }, [virtualRef])
562
+
563
+ const refProps =
564
+ getReferenceProps?.({
565
+ ...rest,
566
+ ref,
567
+ }) || null
568
+
569
+ // Wrap setReference in startTransition to avoid React #185 (setState during render)
570
+ const safeSetReference = React.useCallback(
571
+ (node: any) => {
572
+ startTransition(() => {
573
+ refs.setReference(node)
574
+ })
575
+ },
576
+ // it was refs.setRefernce but its stable and refs is undefined on server
577
+ [refs]
578
+ )
579
+
580
+ const shouldHandleInHover = isWeb && scope
581
+ const composedRefs = useComposedRefs(
582
+ forwardedRef,
583
+ ref,
584
+ // web handles this onMouseEnter below so it can support multiple targets + hovering
585
+ shouldHandleInHover ? undefined : safeSetReference
586
+ )
587
+
588
+ return (
589
+ <GuiView
590
+ {...rest}
591
+ {...refProps}
592
+ ref={composedRefs}
593
+ {...(shouldHandleInHover && {
594
+ // scoped poppers with multiple triggers: set the reference on
595
+ // mouseEnter so floating-ui positions relative to the hovered
596
+ // trigger, not the last one rendered.
597
+ //
598
+ // flushSync is critical here: without it, setReference batches
599
+ // with React's async state updates and the arrow/content position
600
+ // computes against the OLD reference element. this causes the
601
+ // arrow to flash at x=0 (top-left) during trigger switches.
602
+ // flushSync forces synchronous commit so update() below reads
603
+ // the correct reference element immediately.
604
+ onMouseEnter: (e) => {
605
+ const el = (e.currentTarget ?? ref.current) as HTMLElement | null
606
+ if (el instanceof HTMLElement) {
607
+ flushSync(() => refs.setReference(el))
608
+ update()
609
+
610
+ if (!refProps) return
611
+
612
+ refProps.onPointerEnter?.(e)
613
+ context.onHoverReference?.(e.nativeEvent)
614
+ }
615
+ },
616
+ onMouseLeave: (e) => {
617
+ context.onLeaveReference?.()
618
+ refProps?.onMouseLeave?.(e)
619
+ },
620
+ })}
621
+ />
622
+ )
623
+ }
624
+ )
625
+
626
+ /* -------------------------------------------------------------------------------------------------
627
+ * PopperContent
628
+ * -----------------------------------------------------------------------------------------------*/
629
+
630
+ type PopperContentElement = GuiElement
631
+
632
+ export type PopperContentProps = SizableStackProps & {
633
+ scope?: string
634
+ /**
635
+ * Enable smooth animation when the content position changes (e.g., when flipping sides)
636
+ */
637
+ animatePosition?: boolean | 'even-when-repositioning'
638
+ /** @deprecated Use `animatePosition` instead */
639
+ enableAnimationForPositionChange?: boolean | 'even-when-repositioning'
640
+ passThrough?: boolean
641
+ }
642
+
643
+ export const PopperContentFrame = styled(YStack, {
644
+ name: 'PopperContent',
645
+
646
+ variants: {
647
+ unstyled: {
648
+ true: {},
649
+ },
650
+
651
+ size: {
652
+ '...size': (val, { tokens }) => {
653
+ return {
654
+ padding: tokens.space[val],
655
+ borderRadius: tokens.radius[val],
656
+ }
657
+ },
658
+ },
659
+ } as const,
660
+ })
661
+
662
+ export const PopperContent = React.forwardRef<PopperContentElement, PopperContentProps>(
663
+ function PopperContent(props, forwardedRef) {
664
+ const {
665
+ scope,
666
+ animatePosition,
667
+ enableAnimationForPositionChange,
668
+ children,
669
+ passThrough,
670
+ unstyled,
671
+ ...rest
672
+ } = props
673
+ const animatePos = animatePosition ?? enableAnimationForPositionChange
674
+ const context = usePopperContext(scope)
675
+
676
+ const {
677
+ strategy,
678
+ placement,
679
+ refs,
680
+ x,
681
+ y,
682
+ getFloatingProps,
683
+ size,
684
+ isPositioned,
685
+ transformOrigin,
686
+ update,
687
+ } = context
688
+
689
+ // keep update() accessible inside safeSetFloating without adding it as a dep
690
+ const updateRef = React.useRef(update)
691
+ updateRef.current = update
692
+
693
+ // ref callback: call refs.setFloating directly (no startTransition) so floating-ui's
694
+ // state update runs synchronously and position is computed on mount.
695
+ // note: ref callbacks fire during the commit phase, not render, so calling setState
696
+ // here is safe - React batches it for the next commit.
697
+ //
698
+ // when animatePosition=true, disableAnimation state changes cycle the DOM node
699
+ // (null then re-mount). we block all null calls here to prevent floating-ui from
700
+ // losing its reference mid-cycle; genuine unmount is handled by the useEffect below.
701
+ // for same-node cycling (animateOnly prop change without remount), refs.setFloating
702
+ // is a no-op in floating-ui (same-node guard), so we call update() to force recompute.
703
+ const lastNodeRef = React.useRef<any>(null)
704
+ const safeSetFloating = React.useCallback(
705
+ (node: any) => {
706
+ const isNewNode = node !== lastNodeRef.current
707
+ if (node) {
708
+ lastNodeRef.current = node
709
+ refs.setFloating(node)
710
+ if (!isNewNode) {
711
+ // same node re-appeared (prop cycling without remount):
712
+ // refs.setFloating is a no-op, so force position recompute
713
+ updateRef.current?.()
714
+ }
715
+ }
716
+ // null calls are blocked: cycling nulls are transient, genuine unmount
717
+ // is handled by the useEffect cleanup below
718
+ },
719
+ [refs.setFloating]
720
+ )
721
+
722
+ // clear floating-ui's reference when the component genuinely unmounts.
723
+ // IMPORTANT: useEffect cleanup is deferred — when PopperContent remounts
724
+ // (e.g. animation prop cycling), the new instance's ref callback fires
725
+ // BEFORE this cleanup runs. without the guard, we'd null out the ref that
726
+ // the new instance just set, causing all subsequent update() calls to
727
+ // early-return (the "stuck tooltip" bug).
728
+ React.useEffect(() => {
729
+ return () => {
730
+ const ourNode = lastNodeRef.current
731
+ // only clear if floating-ui still points to OUR node — if a new
732
+ // instance already set a different node, don't touch it
733
+ if (ourNode && refs.floating.current === ourNode) {
734
+ refs.setFloating(null)
735
+ }
736
+ lastNodeRef.current = null
737
+ }
738
+ }, [])
739
+
740
+ const contentRefs = useComposedRefs<any>(safeSetFloating, forwardedRef)
741
+
742
+ const [needsMeasure, setNeedsMeasure] = React.useState(animatePos)
743
+
744
+ useIsomorphicLayoutEffect(() => {
745
+ if (needsMeasure && x && y) {
746
+ setNeedsMeasure(false)
747
+ }
748
+ }, [needsMeasure, animatePos, x, y])
749
+
750
+ // track whether we've ever been positioned. floating-ui resets isPositioned
751
+ // to false when open changes to false (e.g. hoverable safePolygon briefly
752
+ // closing). without this, the brief close disables animation and causes
753
+ // position jumps when the popover reopens at the new trigger.
754
+ const hasBeenPositioned = React.useRef(false)
755
+ const lastGoodPosition = React.useRef({ x: 0, y: 0 })
756
+ if (x !== 0 || y !== 0) {
757
+ // always track the latest computed position so that when a new reference
758
+ // is set while closed (e.g. content → gap → different trigger), the
759
+ // effectiveX/Y fallback uses the fresh position, not the stale one
760
+ lastGoodPosition.current = { x, y }
761
+ if (isPositioned) {
762
+ hasBeenPositioned.current = true
763
+ }
764
+ }
765
+
766
+ // use the last known good position when floating-ui provides 0,0.
767
+ // this happens in two cases:
768
+ // 1. close/reopen cycle: isPositioned resets to false
769
+ // 2. trigger switch: reference element changes, floating-ui briefly
770
+ // provides x=0,y=0 while isPositioned is still true, causing the
771
+ // animation driver to animate toward (0,0) for 2-3 frames
772
+ const brieflyZero = hasBeenPositioned.current && x === 0 && y === 0
773
+ const effectiveX = brieflyZero ? lastGoodPosition.current.x : x
774
+ const effectiveY = brieflyZero ? lastGoodPosition.current.y : y
775
+
776
+ // only hide before the very first positioning
777
+ const hide = !hasBeenPositioned.current && effectiveX === 0 && effectiveY === 0
778
+
779
+ const disableAnimationProp =
780
+ // if they want to animate also when re-positioning allow it
781
+ animatePos === 'even-when-repositioning'
782
+ ? needsMeasure
783
+ : (!hasBeenPositioned.current && !isPositioned) || needsMeasure
784
+
785
+ const [disableAnimation, setDisableAnimation] = React.useState(disableAnimationProp)
786
+
787
+ // set in an effect so we apply the css transition only after the element is positioned,
788
+ // not on the first render (which would animate from y=0 to the actual position)
789
+ React.useEffect(() => {
790
+ setDisableAnimation(disableAnimationProp)
791
+ }, [disableAnimationProp])
792
+
793
+ const positionProps = hide
794
+ ? {} // omit x/y when hiding - prevents motion driver from animating from origin
795
+ : { x: effectiveX || 0, y: effectiveY || 0 }
796
+
797
+ const frameProps = {
798
+ ref: contentRefs,
799
+ ...positionProps,
800
+ top: 0,
801
+ left: 0,
802
+ position: strategy,
803
+ opacity: hide ? 0 : 1,
804
+ ...(animatePos && {
805
+ transition: rest.transition,
806
+ // animateOnly: [] turns off transitions while keeping styles applied,
807
+ // letting the element move to its position silently before animations start
808
+ animateOnly: disableAnimation ? [] : rest.animateOnly,
809
+ animatePresence: false,
810
+ }),
811
+ }
812
+
813
+ // outer frame because we explicitly don't want animation to apply to this
814
+
815
+ const { style, ...floatingProps } = getFloatingProps
816
+ ? getFloatingProps(frameProps)
817
+ : frameProps
818
+
819
+ // Compute the CSS transform-origin value from middleware data
820
+ const transformOriginStyle =
821
+ isWeb && transformOrigin
822
+ ? { transformOrigin: `${transformOrigin.x} ${transformOrigin.y}` }
823
+ : undefined
824
+
825
+ return (
826
+ <LayoutMeasurementController disable={!context.open}>
827
+ <GuiView
828
+ passThrough={passThrough}
829
+ ref={contentRefs}
830
+ direction={rest.direction}
831
+ {...(passThrough ? null : floatingProps)}
832
+ {...(!passThrough &&
833
+ animatePos && {
834
+ 'data-popper-animate-position': 'true',
835
+ })}
836
+ >
837
+ <PopperContentFrame
838
+ key="popper-content-frame"
839
+ passThrough={passThrough}
840
+ unstyled={unstyled}
841
+ {...(!passThrough && {
842
+ 'data-placement': placement,
843
+ 'data-strategy': strategy,
844
+ size,
845
+ ...style,
846
+ ...transformOriginStyle,
847
+ ...rest,
848
+ })}
849
+ >
850
+ {children}
851
+ </PopperContentFrame>
852
+ </GuiView>
853
+ </LayoutMeasurementController>
854
+ )
855
+ }
856
+ )
857
+
858
+ /* -------------------------------------------------------------------------------------------------
859
+ * PopperArrow
860
+ * -----------------------------------------------------------------------------------------------*/
861
+
862
+ export type PopperArrowExtraProps = {
863
+ offset?: number
864
+ size?: SizeTokens
865
+ scope?: string
866
+ /**
867
+ * Enable smooth animation when the arrow position changes
868
+ */
869
+ animatePosition?: boolean
870
+ }
871
+
872
+ export type PopperArrowProps = YStackProps & PopperArrowExtraProps
873
+
874
+ export const PopperArrowFrame = styled(YStack, {
875
+ name: 'PopperArrow',
876
+
877
+ variants: {
878
+ unstyled: {
879
+ false: {
880
+ borderColor: '$borderColor',
881
+ backgroundColor: '$background',
882
+ position: 'relative',
883
+ },
884
+ },
885
+ } as const,
886
+
887
+ defaultVariants: {
888
+ unstyled: process.env.HANZO_GUI_HEADLESS === '1',
889
+ },
890
+ })
891
+
892
+ const PopperArrowOuterFrame = styled(YStack, {
893
+ name: 'PopperArrowOuter',
894
+
895
+ variants: {
896
+ unstyled: {
897
+ false: {
898
+ position: 'absolute',
899
+ zIndex: 1_000_000,
900
+ pointerEvents: 'none',
901
+ overflow: 'hidden',
902
+ alignItems: 'center',
903
+ justifyContent: 'center',
904
+ },
905
+ },
906
+ } as const,
907
+
908
+ defaultVariants: {
909
+ unstyled: process.env.HANZO_GUI_HEADLESS === '1',
910
+ },
911
+ })
912
+
913
+ const opposites = {
914
+ top: 'bottom',
915
+ right: 'left',
916
+ bottom: 'top',
917
+ left: 'right',
918
+ } as const
919
+
920
+ type Sides = keyof typeof opposites
921
+
922
+ export const PopperArrow = React.forwardRef<GuiElement, PopperArrowProps>(
923
+ function PopperArrow(propsIn, forwardedRef) {
924
+ const { scope, animatePosition, transition, ...rest } = propsIn
925
+ const { offset, size: sizeProp, borderWidth = 0, ...arrowProps } = rest
926
+
927
+ const context = usePopperContext(scope)
928
+
929
+ // TODO: get rid! at the very least move up to Popover and simplify
930
+ const sizeVal =
931
+ typeof sizeProp === 'number'
932
+ ? sizeProp
933
+ : getVariableValue(
934
+ getSpace(sizeProp ?? context.size, {
935
+ shift: -2,
936
+ bounds: [2],
937
+ })
938
+ )
939
+
940
+ const size = Math.max(0, +sizeVal)
941
+
942
+ const { placement } = context
943
+ const refs = useComposedRefs(context.arrowRef, forwardedRef)
944
+
945
+ // Sometimes floating-ui can return NaN during orientation or screen size changes on native
946
+ // so we explicitly force the x,y position types as a number
947
+ const x = (context.arrowStyle?.x as number) || 0
948
+ const y = (context.arrowStyle?.y as number) || 0
949
+
950
+ // hide arrow until floating-ui has computed its position to prevent
951
+ // flash at x=0 during initial render or trigger switches in hydration
952
+ const arrowPositioned = context.arrowStyle != null
953
+
954
+ const primaryPlacement = (placement ? placement.split('-')[0] : 'top') as Sides
955
+
956
+ const arrowStyle: ViewProps = { x, y, width: size, height: size }
957
+
958
+ const innerArrowStyle: ViewProps = {}
959
+ const isVertical = primaryPlacement === 'bottom' || primaryPlacement === 'top'
960
+
961
+ if (primaryPlacement) {
962
+ // allows for extra diagonal size
963
+ arrowStyle[isVertical ? 'width' : 'height'] = size * 2
964
+ const oppSide = opposites[primaryPlacement]
965
+ if (oppSide) {
966
+ arrowStyle[oppSide] = -size
967
+ innerArrowStyle[oppSide] = size / 2
968
+ }
969
+ if (oppSide === 'top' || oppSide === 'bottom') {
970
+ arrowStyle.left = 0
971
+ }
972
+ if (oppSide === 'left' || oppSide === 'right') {
973
+ arrowStyle.top = 0
974
+ }
975
+
976
+ // send the Arrow's offset up to Popper
977
+ useIsomorphicLayoutEffect(() => {
978
+ context.onArrowSize?.(size)
979
+ }, [size, context.onArrowSize])
980
+ }
981
+
982
+ // outer frame to cut off for ability to have nicer shadows/borders
983
+ return (
984
+ <PopperArrowOuterFrame
985
+ ref={refs}
986
+ {...arrowStyle}
987
+ {...(!arrowPositioned && { opacity: 0 })}
988
+ {...(animatePosition && {
989
+ transition,
990
+ animateOnly: ['transform'],
991
+ animatePresence: false,
992
+ })}
993
+ >
994
+ <PopperArrowFrame
995
+ width={size}
996
+ height={size}
997
+ {...arrowProps}
998
+ {...innerArrowStyle}
999
+ rotate="45deg"
1000
+ {...(primaryPlacement === 'bottom' && {
1001
+ borderLeftWidth: borderWidth,
1002
+ borderTopWidth: borderWidth,
1003
+ })}
1004
+ {...(primaryPlacement === 'top' && {
1005
+ borderBottomWidth: borderWidth,
1006
+ borderRightWidth: borderWidth,
1007
+ })}
1008
+ {...(primaryPlacement === 'right' && {
1009
+ borderLeftWidth: borderWidth,
1010
+ borderBottomWidth: borderWidth,
1011
+ })}
1012
+ {...(primaryPlacement === 'left' && {
1013
+ borderTopWidth: borderWidth,
1014
+ borderRightWidth: borderWidth,
1015
+ })}
1016
+ />
1017
+ </PopperArrowOuterFrame>
1018
+ )
1019
+ }
1020
+ )
1021
+
1022
+ /* -----------------------------------------------------------------------------------------------*/