@tamagui/popper 2.0.0-rc.8 → 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/dist/cjs/Popper.cjs +519 -416
- package/dist/cjs/Popper.native.js +535 -402
- package/dist/cjs/Popper.native.js.map +1 -1
- package/dist/cjs/index.cjs +7 -5
- package/dist/cjs/index.native.js +7 -5
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/esm/Popper.mjs +491 -390
- package/dist/esm/Popper.mjs.map +1 -1
- package/dist/esm/Popper.native.js +514 -384
- package/dist/esm/Popper.native.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -6
- package/dist/jsx/Popper.mjs +491 -390
- package/dist/jsx/Popper.mjs.map +1 -1
- package/dist/jsx/Popper.native.js +535 -402
- package/dist/jsx/Popper.native.js.map +1 -1
- package/dist/jsx/index.js +1 -1
- package/dist/jsx/index.js.map +1 -6
- package/dist/jsx/index.native.js +7 -5
- package/package.json +15 -19
- package/src/Popper.tsx +319 -133
- package/types/Popper.d.ts +10 -4
- package/types/Popper.d.ts.map +1 -1
- package/dist/cjs/Popper.js +0 -437
- package/dist/cjs/Popper.js.map +0 -6
- package/dist/cjs/index.js +0 -15
- package/dist/cjs/index.js.map +0 -6
- package/dist/esm/Popper.js +0 -438
- package/dist/esm/Popper.js.map +0 -6
- package/dist/jsx/Popper.js +0 -438
- package/dist/jsx/Popper.js.map +0 -6
package/src/Popper.tsx
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
// adapted from radix-ui popper
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
2
3
|
import { useComposedRefs } from '@tamagui/compose-refs'
|
|
3
4
|
import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
|
|
4
|
-
import type { SizeTokens,
|
|
5
|
+
import type { SizeTokens, TamaguiElement, ViewProps } from '@tamagui/core'
|
|
5
6
|
import {
|
|
6
7
|
LayoutMeasurementController,
|
|
7
8
|
View as TamaguiView,
|
|
8
9
|
createStyledContext,
|
|
9
10
|
getVariableValue,
|
|
11
|
+
registerLayoutNode,
|
|
10
12
|
styled,
|
|
11
|
-
useProps,
|
|
12
13
|
} from '@tamagui/core'
|
|
14
|
+
import type { PopupTriggerMap } from '@tamagui/floating'
|
|
15
|
+
import { FloatingOverrideContext } from '@tamagui/floating'
|
|
13
16
|
import type {
|
|
14
17
|
Coords,
|
|
15
18
|
Middleware,
|
|
16
19
|
OffsetOptions,
|
|
17
20
|
Placement,
|
|
21
|
+
ReferenceType,
|
|
18
22
|
Side,
|
|
19
23
|
SizeOptions,
|
|
20
24
|
Strategy,
|
|
@@ -22,8 +26,8 @@ import type {
|
|
|
22
26
|
} from '@tamagui/floating'
|
|
23
27
|
import {
|
|
24
28
|
arrow,
|
|
25
|
-
autoUpdate,
|
|
26
29
|
flip,
|
|
30
|
+
getOverflowAncestors,
|
|
27
31
|
offset as offsetFn,
|
|
28
32
|
platform,
|
|
29
33
|
shift,
|
|
@@ -35,7 +39,7 @@ import type { SizableStackProps, YStackProps } from '@tamagui/stacks'
|
|
|
35
39
|
import { YStack } from '@tamagui/stacks'
|
|
36
40
|
import { startTransition } from '@tamagui/start-transition'
|
|
37
41
|
import * as React from 'react'
|
|
38
|
-
import { Keyboard,
|
|
42
|
+
import { Keyboard, useWindowDimensions } from 'react-native'
|
|
39
43
|
|
|
40
44
|
type ShiftProps = typeof shift extends (options: infer Opts) => void ? Opts : never
|
|
41
45
|
type FlipProps = typeof flip extends (options: infer Opts) => void ? Opts : never
|
|
@@ -70,15 +74,18 @@ export const PopperPositionContext = createStyledContext
|
|
|
70
74
|
export const { useStyledContext: usePopperContext, Provider: PopperProviderFast } =
|
|
71
75
|
PopperContextFast
|
|
72
76
|
|
|
73
|
-
export type PopperContextSlowValue =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
export type PopperContextSlowValue = Pick<
|
|
78
|
+
UseFloatingReturn,
|
|
79
|
+
'getReferenceProps' | 'update' | 'refs'
|
|
80
|
+
> & {
|
|
81
|
+
onHoverReference?: (event: any) => void
|
|
82
|
+
onLeaveReference?: () => void
|
|
83
|
+
triggerElements?: PopupTriggerMap
|
|
84
|
+
}
|
|
78
85
|
|
|
79
86
|
export const PopperContextSlow = createStyledContext<PopperContextSlowValue>(
|
|
80
87
|
// since we always provide this we can avoid setting here
|
|
81
|
-
{} as
|
|
88
|
+
{} as PopperContextSlowValue,
|
|
82
89
|
'PopperSlow__'
|
|
83
90
|
)
|
|
84
91
|
|
|
@@ -91,7 +98,32 @@ export const PopperProvider = ({
|
|
|
91
98
|
children,
|
|
92
99
|
...context
|
|
93
100
|
}: PopperContextValue & { scope?: string; children?: React.ReactNode }) => {
|
|
94
|
-
|
|
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
|
+
)
|
|
95
127
|
|
|
96
128
|
return (
|
|
97
129
|
<PopperProviderFast scope={scope} {...context}>
|
|
@@ -102,24 +134,6 @@ export const PopperProvider = ({
|
|
|
102
134
|
)
|
|
103
135
|
}
|
|
104
136
|
|
|
105
|
-
// avoid position based re-rendering
|
|
106
|
-
function getContextSlow(context: PopperContextValue): PopperContextSlowValue {
|
|
107
|
-
return {
|
|
108
|
-
refs: context.refs,
|
|
109
|
-
size: context.size,
|
|
110
|
-
arrowRef: context.arrowRef,
|
|
111
|
-
arrowStyle: context.arrowStyle,
|
|
112
|
-
onArrowSize: context.onArrowSize,
|
|
113
|
-
hasFloating: context.hasFloating,
|
|
114
|
-
strategy: context.strategy,
|
|
115
|
-
update: context.update,
|
|
116
|
-
context: context.context,
|
|
117
|
-
getFloatingProps: context.getFloatingProps,
|
|
118
|
-
getReferenceProps: context.getReferenceProps,
|
|
119
|
-
open: context.open,
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
137
|
export type PopperProps = {
|
|
124
138
|
/**
|
|
125
139
|
* Popper is a component used by other components to create interfaces, so scope is required
|
|
@@ -141,7 +155,9 @@ export type PopperProps = {
|
|
|
141
155
|
placement?: Placement
|
|
142
156
|
|
|
143
157
|
/**
|
|
144
|
-
*
|
|
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 }
|
|
145
161
|
* @see https://floating-ui.com/docs/shift
|
|
146
162
|
*/
|
|
147
163
|
stayInFrame?: ShiftProps | boolean
|
|
@@ -246,6 +262,58 @@ const transformOriginMiddleware = (options: {
|
|
|
246
262
|
},
|
|
247
263
|
})
|
|
248
264
|
|
|
265
|
+
// replaces floating-ui's autoUpdate with tamagui'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 tamaguiAutoUpdate(
|
|
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 tamagui'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
|
+
|
|
249
317
|
export function Popper(props: PopperProps) {
|
|
250
318
|
const {
|
|
251
319
|
children,
|
|
@@ -266,33 +334,35 @@ export function Popper(props: PopperProps) {
|
|
|
266
334
|
const [arrowSize, setArrowSize] = React.useState(0)
|
|
267
335
|
const offsetOptions = offset ?? arrowSize
|
|
268
336
|
const floatingStyle = React.useRef({})
|
|
269
|
-
const isOpen = passThrough ? false : open
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
: platform,
|
|
286
|
-
middleware: [
|
|
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)
|
|
287
353
|
stayInFrame
|
|
288
|
-
? shift(
|
|
354
|
+
? shift({
|
|
355
|
+
padding: 10,
|
|
356
|
+
mainAxis: true,
|
|
357
|
+
crossAxis: false,
|
|
358
|
+
...(typeof stayInFrame === 'object' ? stayInFrame : null),
|
|
359
|
+
})
|
|
289
360
|
: (null as any),
|
|
290
|
-
allowFlip ? flip(typeof allowFlip === 'boolean' ? {} : allowFlip) : (null as any),
|
|
291
361
|
arrowEl ? arrow({ element: arrowEl }) : (null as any),
|
|
292
|
-
typeof offsetOptions !== 'undefined' ? offsetFn(offsetOptions) : (null as any),
|
|
293
362
|
checkFloating,
|
|
294
363
|
process.env.TAMAGUI_TARGET !== 'native' && resize
|
|
295
364
|
? sizeMiddleware({
|
|
365
|
+
padding: typeof stayInFrame === 'object' ? stayInFrame.padding : 10,
|
|
296
366
|
apply({ availableHeight, availableWidth }) {
|
|
297
367
|
if (passThrough) {
|
|
298
368
|
return
|
|
@@ -343,7 +413,25 @@ export function Popper(props: PopperProps) {
|
|
|
343
413
|
arrowWidth: arrowSize,
|
|
344
414
|
})
|
|
345
415
|
: (null as any),
|
|
346
|
-
].filter(Boolean)
|
|
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 : tamaguiAutoUpdate,
|
|
425
|
+
platform:
|
|
426
|
+
(disableRTL ?? setupOptions.disableRTL)
|
|
427
|
+
? {
|
|
428
|
+
...platform,
|
|
429
|
+
isRTL(element) {
|
|
430
|
+
return false
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
: platform,
|
|
434
|
+
middleware: middlewareRef.current,
|
|
347
435
|
})
|
|
348
436
|
|
|
349
437
|
if (process.env.TAMAGUI_TARGET !== 'native') {
|
|
@@ -399,8 +487,6 @@ export function Popper(props: PopperProps) {
|
|
|
399
487
|
}, [passThrough, dimensions, keyboardOpen])
|
|
400
488
|
}
|
|
401
489
|
|
|
402
|
-
// memoize since we round x/y, floating-ui doesn't by default which can cause tons of updates
|
|
403
|
-
// if the floating element is inside something animating with a spring
|
|
404
490
|
const popperContext = React.useMemo(() => {
|
|
405
491
|
return {
|
|
406
492
|
size,
|
|
@@ -417,20 +503,20 @@ export function Popper(props: PopperProps) {
|
|
|
417
503
|
}, [
|
|
418
504
|
open,
|
|
419
505
|
size,
|
|
420
|
-
floating
|
|
421
|
-
floating.y,
|
|
422
|
-
floating.placement,
|
|
506
|
+
floating,
|
|
423
507
|
JSON.stringify(middlewareData.arrow || null),
|
|
424
508
|
JSON.stringify(middlewareData.transformOrigin || null),
|
|
425
|
-
floating.isPositioned,
|
|
426
509
|
])
|
|
427
510
|
|
|
428
511
|
return (
|
|
429
|
-
<
|
|
430
|
-
|
|
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}>
|
|
431
517
|
{children}
|
|
432
|
-
</
|
|
433
|
-
</
|
|
518
|
+
</FloatingOverrideContext.Provider>
|
|
519
|
+
</PopperProvider>
|
|
434
520
|
)
|
|
435
521
|
}
|
|
436
522
|
|
|
@@ -452,10 +538,25 @@ export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
|
|
|
452
538
|
const context = usePopperContextSlow(scope)
|
|
453
539
|
const { getReferenceProps, refs, update } = context
|
|
454
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])
|
|
455
554
|
|
|
456
555
|
React.useEffect(() => {
|
|
457
556
|
if (virtualRef) {
|
|
458
557
|
refs.setReference(virtualRef.current)
|
|
558
|
+
// recompute position after setting virtual reference
|
|
559
|
+
update()
|
|
459
560
|
}
|
|
460
561
|
}, [virtualRef])
|
|
461
562
|
|
|
@@ -472,7 +573,8 @@ export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
|
|
|
472
573
|
refs.setReference(node)
|
|
473
574
|
})
|
|
474
575
|
},
|
|
475
|
-
|
|
576
|
+
// it was refs.setRefernce but its stable and refs is undefined on server
|
|
577
|
+
[refs]
|
|
476
578
|
)
|
|
477
579
|
|
|
478
580
|
const shouldHandleInHover = isWeb && scope
|
|
@@ -489,22 +591,30 @@ export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
|
|
|
489
591
|
{...refProps}
|
|
490
592
|
ref={composedRefs}
|
|
491
593
|
{...(shouldHandleInHover && {
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
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.
|
|
495
604
|
onMouseEnter: (e) => {
|
|
496
|
-
|
|
497
|
-
|
|
605
|
+
const el = (e.currentTarget ?? ref.current) as HTMLElement | null
|
|
606
|
+
if (el instanceof HTMLElement) {
|
|
607
|
+
flushSync(() => refs.setReference(el))
|
|
608
|
+
update()
|
|
498
609
|
|
|
499
|
-
if (!refProps)
|
|
500
|
-
return
|
|
501
|
-
}
|
|
610
|
+
if (!refProps) return
|
|
502
611
|
|
|
503
612
|
refProps.onPointerEnter?.(e)
|
|
504
|
-
|
|
613
|
+
context.onHoverReference?.(e.nativeEvent)
|
|
505
614
|
}
|
|
506
615
|
},
|
|
507
616
|
onMouseLeave: (e) => {
|
|
617
|
+
context.onLeaveReference?.()
|
|
508
618
|
refProps?.onMouseLeave?.(e)
|
|
509
619
|
},
|
|
510
620
|
})}
|
|
@@ -535,11 +645,7 @@ export const PopperContentFrame = styled(YStack, {
|
|
|
535
645
|
|
|
536
646
|
variants: {
|
|
537
647
|
unstyled: {
|
|
538
|
-
|
|
539
|
-
size: '$true',
|
|
540
|
-
backgroundColor: '$background',
|
|
541
|
-
alignItems: 'center',
|
|
542
|
-
},
|
|
648
|
+
true: {},
|
|
543
649
|
},
|
|
544
650
|
|
|
545
651
|
size: {
|
|
@@ -551,20 +657,25 @@ export const PopperContentFrame = styled(YStack, {
|
|
|
551
657
|
},
|
|
552
658
|
},
|
|
553
659
|
} as const,
|
|
554
|
-
|
|
555
|
-
defaultVariants: {
|
|
556
|
-
unstyled: process.env.TAMAGUI_HEADLESS === '1',
|
|
557
|
-
},
|
|
558
660
|
})
|
|
559
661
|
|
|
560
662
|
export const PopperContent = React.forwardRef<PopperContentElement, PopperContentProps>(
|
|
561
663
|
function PopperContent(props, forwardedRef) {
|
|
664
|
+
// detect controlled animatePosition before destructuring. when the user passes
|
|
665
|
+
// animatePosition (even with a currently-falsy value like undefined or false),
|
|
666
|
+
// toggling it later must not flip 'transition' presence on the inner View - that
|
|
667
|
+
// would change useComponentState's hasAnimationProp mid-life, conditionally calling
|
|
668
|
+
// useAnimations/usePresence and tripping React's "Should have a queue" invariant.
|
|
669
|
+
const isAnimatePosControlled =
|
|
670
|
+
'animatePosition' in props || 'enableAnimationForPositionChange' in props
|
|
671
|
+
|
|
562
672
|
const {
|
|
563
673
|
scope,
|
|
564
674
|
animatePosition,
|
|
565
675
|
enableAnimationForPositionChange,
|
|
566
676
|
children,
|
|
567
677
|
passThrough,
|
|
678
|
+
unstyled,
|
|
568
679
|
...rest
|
|
569
680
|
} = props
|
|
570
681
|
const animatePos = animatePosition ?? enableAnimationForPositionChange
|
|
@@ -580,19 +691,60 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
|
|
|
580
691
|
size,
|
|
581
692
|
isPositioned,
|
|
582
693
|
transformOrigin,
|
|
694
|
+
update,
|
|
583
695
|
} = context
|
|
584
696
|
|
|
585
|
-
//
|
|
586
|
-
|
|
697
|
+
// keep update() accessible inside safeSetFloating without adding it as a dep
|
|
698
|
+
const updateRef = React.useRef(update)
|
|
699
|
+
updateRef.current = update
|
|
700
|
+
|
|
701
|
+
// ref callback: call refs.setFloating directly (no startTransition) so floating-ui's
|
|
702
|
+
// state update runs synchronously and position is computed on mount.
|
|
703
|
+
// note: ref callbacks fire during the commit phase, not render, so calling setState
|
|
704
|
+
// here is safe - React batches it for the next commit.
|
|
705
|
+
//
|
|
706
|
+
// when animatePosition=true, disableAnimation state changes cycle the DOM node
|
|
707
|
+
// (null then re-mount). we block all null calls here to prevent floating-ui from
|
|
708
|
+
// losing its reference mid-cycle; genuine unmount is handled by the useEffect below.
|
|
709
|
+
// for same-node cycling (animateOnly prop change without remount), refs.setFloating
|
|
710
|
+
// is a no-op in floating-ui (same-node guard), so we call update() to force recompute.
|
|
711
|
+
const lastNodeRef = React.useRef<any>(null)
|
|
587
712
|
const safeSetFloating = React.useCallback(
|
|
588
713
|
(node: any) => {
|
|
589
|
-
|
|
714
|
+
const isNewNode = node !== lastNodeRef.current
|
|
715
|
+
if (node) {
|
|
716
|
+
lastNodeRef.current = node
|
|
590
717
|
refs.setFloating(node)
|
|
591
|
-
|
|
718
|
+
if (!isNewNode) {
|
|
719
|
+
// same node re-appeared (prop cycling without remount):
|
|
720
|
+
// refs.setFloating is a no-op, so force position recompute
|
|
721
|
+
updateRef.current?.()
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// null calls are blocked: cycling nulls are transient, genuine unmount
|
|
725
|
+
// is handled by the useEffect cleanup below
|
|
592
726
|
},
|
|
593
727
|
[refs.setFloating]
|
|
594
728
|
)
|
|
595
729
|
|
|
730
|
+
// clear floating-ui's reference when the component genuinely unmounts.
|
|
731
|
+
// IMPORTANT: useEffect cleanup is deferred — when PopperContent remounts
|
|
732
|
+
// (e.g. animation prop cycling), the new instance's ref callback fires
|
|
733
|
+
// BEFORE this cleanup runs. without the guard, we'd null out the ref that
|
|
734
|
+
// the new instance just set, causing all subsequent update() calls to
|
|
735
|
+
// early-return (the "stuck tooltip" bug).
|
|
736
|
+
React.useEffect(() => {
|
|
737
|
+
return () => {
|
|
738
|
+
const ourNode = lastNodeRef.current
|
|
739
|
+
// only clear if floating-ui still points to OUR node — if a new
|
|
740
|
+
// instance already set a different node, don't touch it
|
|
741
|
+
if (ourNode && refs.floating.current === ourNode) {
|
|
742
|
+
refs.setFloating(null)
|
|
743
|
+
}
|
|
744
|
+
lastNodeRef.current = null
|
|
745
|
+
}
|
|
746
|
+
}, [])
|
|
747
|
+
|
|
596
748
|
const contentRefs = useComposedRefs<any>(safeSetFloating, forwardedRef)
|
|
597
749
|
|
|
598
750
|
const [needsMeasure, setNeedsMeasure] = React.useState(animatePos)
|
|
@@ -603,28 +755,52 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
|
|
|
603
755
|
}
|
|
604
756
|
}, [needsMeasure, animatePos, x, y])
|
|
605
757
|
|
|
606
|
-
//
|
|
607
|
-
|
|
758
|
+
// track whether we've ever been positioned. floating-ui resets isPositioned
|
|
759
|
+
// to false when open changes to false (e.g. hoverable safePolygon briefly
|
|
760
|
+
// closing). without this, the brief close disables animation and causes
|
|
761
|
+
// position jumps when the popover reopens at the new trigger.
|
|
762
|
+
const hasBeenPositioned = React.useRef(false)
|
|
763
|
+
const lastGoodPosition = React.useRef({ x: 0, y: 0 })
|
|
764
|
+
if (x !== 0 || y !== 0) {
|
|
765
|
+
// always track the latest computed position so that when a new reference
|
|
766
|
+
// is set while closed (e.g. content → gap → different trigger), the
|
|
767
|
+
// effectiveX/Y fallback uses the fresh position, not the stale one
|
|
768
|
+
lastGoodPosition.current = { x, y }
|
|
769
|
+
if (isPositioned) {
|
|
770
|
+
hasBeenPositioned.current = true
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// use the last known good position when floating-ui provides 0,0.
|
|
775
|
+
// this happens in two cases:
|
|
776
|
+
// 1. close/reopen cycle: isPositioned resets to false
|
|
777
|
+
// 2. trigger switch: reference element changes, floating-ui briefly
|
|
778
|
+
// provides x=0,y=0 while isPositioned is still true, causing the
|
|
779
|
+
// animation driver to animate toward (0,0) for 2-3 frames
|
|
780
|
+
const brieflyZero = hasBeenPositioned.current && x === 0 && y === 0
|
|
781
|
+
const effectiveX = brieflyZero ? lastGoodPosition.current.x : x
|
|
782
|
+
const effectiveY = brieflyZero ? lastGoodPosition.current.y : y
|
|
783
|
+
|
|
784
|
+
// only hide before the very first positioning
|
|
785
|
+
const hide = !hasBeenPositioned.current && effectiveX === 0 && effectiveY === 0
|
|
608
786
|
|
|
609
787
|
const disableAnimationProp =
|
|
610
788
|
// if they want to animate also when re-positioning allow it
|
|
611
789
|
animatePos === 'even-when-repositioning'
|
|
612
790
|
? needsMeasure
|
|
613
|
-
: !isPositioned || needsMeasure
|
|
791
|
+
: (!hasBeenPositioned.current && !isPositioned) || needsMeasure
|
|
614
792
|
|
|
615
793
|
const [disableAnimation, setDisableAnimation] = React.useState(disableAnimationProp)
|
|
616
794
|
|
|
617
|
-
//
|
|
795
|
+
// set in an effect so we apply the css transition only after the element is positioned,
|
|
796
|
+
// not on the first render (which would animate from y=0 to the actual position)
|
|
618
797
|
React.useEffect(() => {
|
|
619
798
|
setDisableAnimation(disableAnimationProp)
|
|
620
799
|
}, [disableAnimationProp])
|
|
621
800
|
|
|
622
|
-
// when position not calculated yet (hide=true means x===0 && y===0),
|
|
623
|
-
// don't pass x/y to avoid motion driver capturing 0,0 as starting position
|
|
624
|
-
// and then animating from 0,0 to the real position (causes visual jump)
|
|
625
801
|
const positionProps = hide
|
|
626
|
-
? {} // omit x/y when hiding - prevents motion from animating from origin
|
|
627
|
-
: { x:
|
|
802
|
+
? {} // omit x/y when hiding - prevents motion driver from animating from origin
|
|
803
|
+
: { x: effectiveX || 0, y: effectiveY || 0 }
|
|
628
804
|
|
|
629
805
|
const frameProps = {
|
|
630
806
|
ref: contentRefs,
|
|
@@ -632,17 +808,18 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
|
|
|
632
808
|
top: 0,
|
|
633
809
|
left: 0,
|
|
634
810
|
position: strategy,
|
|
635
|
-
opacity: 1,
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
811
|
+
opacity: hide ? 0 : 1,
|
|
812
|
+
// when animatePosition is controlled by the user, always emit these keys with
|
|
813
|
+
// safe no-op values (transition: undefined, animateOnly: []) so the inner
|
|
814
|
+
// View's hook count stays stable across animatePos toggles. animatePresence
|
|
815
|
+
// must always be false here too, to short-circuit usePresence consistently.
|
|
816
|
+
...(isAnimatePosControlled && {
|
|
817
|
+
transition: animatePos ? rest.transition : undefined,
|
|
818
|
+
// animateOnly: [] turns off transitions while keeping styles applied,
|
|
819
|
+
// letting the element move to its position silently before animations start
|
|
820
|
+
animateOnly: animatePos && !disableAnimation ? rest.animateOnly : [],
|
|
640
821
|
animatePresence: false,
|
|
641
822
|
}),
|
|
642
|
-
...(hide && {
|
|
643
|
-
opacity: 0,
|
|
644
|
-
animateOnly: [],
|
|
645
|
-
}),
|
|
646
823
|
}
|
|
647
824
|
|
|
648
825
|
// outer frame because we explicitly don't want animation to apply to this
|
|
@@ -658,33 +835,34 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
|
|
|
658
835
|
: undefined
|
|
659
836
|
|
|
660
837
|
return (
|
|
661
|
-
<
|
|
662
|
-
|
|
663
|
-
ref={contentRefs}
|
|
664
|
-
contain="layout style"
|
|
665
|
-
{...(passThrough ? null : floatingProps)}
|
|
666
|
-
{...(!passThrough &&
|
|
667
|
-
animatePos && {
|
|
668
|
-
// marker for animation driver to know this is a popper element
|
|
669
|
-
// that needs special handling for position animation interruption
|
|
670
|
-
'data-popper-animate-position': 'true',
|
|
671
|
-
})}
|
|
672
|
-
>
|
|
673
|
-
<PopperContentFrame
|
|
674
|
-
key="popper-content-frame"
|
|
838
|
+
<LayoutMeasurementController disable={!context.open}>
|
|
839
|
+
<TamaguiView
|
|
675
840
|
passThrough={passThrough}
|
|
676
|
-
{
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
})}
|
|
841
|
+
ref={contentRefs}
|
|
842
|
+
direction={rest.direction}
|
|
843
|
+
{...(passThrough ? null : floatingProps)}
|
|
844
|
+
{...(!passThrough &&
|
|
845
|
+
animatePos && {
|
|
846
|
+
'data-popper-animate-position': 'true',
|
|
847
|
+
})}
|
|
684
848
|
>
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
849
|
+
<PopperContentFrame
|
|
850
|
+
key="popper-content-frame"
|
|
851
|
+
passThrough={passThrough}
|
|
852
|
+
unstyled={unstyled}
|
|
853
|
+
{...(!passThrough && {
|
|
854
|
+
'data-placement': placement,
|
|
855
|
+
'data-strategy': strategy,
|
|
856
|
+
size,
|
|
857
|
+
...style,
|
|
858
|
+
...transformOriginStyle,
|
|
859
|
+
...rest,
|
|
860
|
+
})}
|
|
861
|
+
>
|
|
862
|
+
{children}
|
|
863
|
+
</PopperContentFrame>
|
|
864
|
+
</TamaguiView>
|
|
865
|
+
</LayoutMeasurementController>
|
|
688
866
|
)
|
|
689
867
|
}
|
|
690
868
|
)
|
|
@@ -755,11 +933,14 @@ type Sides = keyof typeof opposites
|
|
|
755
933
|
|
|
756
934
|
export const PopperArrow = React.forwardRef<TamaguiElement, PopperArrowProps>(
|
|
757
935
|
function PopperArrow(propsIn, forwardedRef) {
|
|
936
|
+
// see PopperContent for why we detect controlled animatePosition before destructuring
|
|
937
|
+
const isAnimatePosControlled = 'animatePosition' in propsIn
|
|
758
938
|
const { scope, animatePosition, transition, ...rest } = propsIn
|
|
759
|
-
const
|
|
760
|
-
const { offset, size: sizeProp, borderWidth = 0, ...arrowProps } = props
|
|
939
|
+
const { offset, size: sizeProp, borderWidth = 0, ...arrowProps } = rest
|
|
761
940
|
|
|
762
941
|
const context = usePopperContext(scope)
|
|
942
|
+
|
|
943
|
+
// TODO: get rid! at the very least move up to Popover and simplify
|
|
763
944
|
const sizeVal =
|
|
764
945
|
typeof sizeProp === 'number'
|
|
765
946
|
? sizeProp
|
|
@@ -780,6 +961,10 @@ export const PopperArrow = React.forwardRef<TamaguiElement, PopperArrowProps>(
|
|
|
780
961
|
const x = (context.arrowStyle?.x as number) || 0
|
|
781
962
|
const y = (context.arrowStyle?.y as number) || 0
|
|
782
963
|
|
|
964
|
+
// hide arrow until floating-ui has computed its position to prevent
|
|
965
|
+
// flash at x=0 during initial render or trigger switches in hydration
|
|
966
|
+
const arrowPositioned = context.arrowStyle != null
|
|
967
|
+
|
|
783
968
|
const primaryPlacement = (placement ? placement.split('-')[0] : 'top') as Sides
|
|
784
969
|
|
|
785
970
|
const arrowStyle: ViewProps = { x, y, width: size, height: size }
|
|
@@ -813,9 +998,10 @@ export const PopperArrow = React.forwardRef<TamaguiElement, PopperArrowProps>(
|
|
|
813
998
|
<PopperArrowOuterFrame
|
|
814
999
|
ref={refs}
|
|
815
1000
|
{...arrowStyle}
|
|
816
|
-
{...(
|
|
817
|
-
|
|
818
|
-
|
|
1001
|
+
{...(!arrowPositioned && { opacity: 0 })}
|
|
1002
|
+
{...(isAnimatePosControlled && {
|
|
1003
|
+
transition: animatePosition ? transition : undefined,
|
|
1004
|
+
animateOnly: animatePosition ? ['transform'] : [],
|
|
819
1005
|
animatePresence: false,
|
|
820
1006
|
})}
|
|
821
1007
|
>
|