@tamagui/popover 2.0.0-rc.9 → 2.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 (49) hide show
  1. package/dist/cjs/Popover.cjs +637 -406
  2. package/dist/cjs/Popover.native.js +651 -436
  3. package/dist/cjs/Popover.native.js.map +1 -1
  4. package/dist/cjs/index.cjs +7 -5
  5. package/dist/cjs/index.native.js +7 -5
  6. package/dist/cjs/index.native.js.map +1 -1
  7. package/dist/cjs/useFloatingContext.cjs +226 -58
  8. package/dist/cjs/useFloatingContext.native.js +28 -26
  9. package/dist/cjs/useFloatingContext.native.js.map +1 -1
  10. package/dist/esm/Popover.mjs +589 -376
  11. package/dist/esm/Popover.mjs.map +1 -1
  12. package/dist/esm/Popover.native.js +605 -408
  13. package/dist/esm/Popover.native.js.map +1 -1
  14. package/dist/esm/index.js +2 -2
  15. package/dist/esm/index.js.map +1 -6
  16. package/dist/esm/useFloatingContext.mjs +200 -34
  17. package/dist/esm/useFloatingContext.mjs.map +1 -1
  18. package/dist/jsx/Popover.mjs +589 -376
  19. package/dist/jsx/Popover.mjs.map +1 -1
  20. package/dist/jsx/Popover.native.js +651 -436
  21. package/dist/jsx/Popover.native.js.map +1 -1
  22. package/dist/jsx/index.js +2 -2
  23. package/dist/jsx/index.js.map +1 -6
  24. package/dist/jsx/index.native.js +7 -5
  25. package/dist/jsx/useFloatingContext.mjs +200 -34
  26. package/dist/jsx/useFloatingContext.mjs.map +1 -1
  27. package/dist/jsx/useFloatingContext.native.js +28 -26
  28. package/dist/jsx/useFloatingContext.native.js.map +1 -1
  29. package/package.json +25 -26
  30. package/src/Popover.tsx +536 -177
  31. package/src/useFloatingContext.tsx +321 -43
  32. package/types/Popover.d.ts +126 -8
  33. package/types/Popover.d.ts.map +1 -1
  34. package/types/useFloatingContext.d.ts +14 -8
  35. package/types/useFloatingContext.d.ts.map +1 -1
  36. package/dist/cjs/Popover.js +0 -394
  37. package/dist/cjs/Popover.js.map +0 -6
  38. package/dist/cjs/index.js +0 -16
  39. package/dist/cjs/index.js.map +0 -6
  40. package/dist/cjs/useFloatingContext.js +0 -74
  41. package/dist/cjs/useFloatingContext.js.map +0 -6
  42. package/dist/esm/Popover.js +0 -415
  43. package/dist/esm/Popover.js.map +0 -6
  44. package/dist/esm/useFloatingContext.js +0 -59
  45. package/dist/esm/useFloatingContext.js.map +0 -6
  46. package/dist/jsx/Popover.js +0 -415
  47. package/dist/jsx/Popover.js.map +0 -6
  48. package/dist/jsx/useFloatingContext.js +0 -59
  49. package/dist/jsx/useFloatingContext.js.map +0 -6
package/src/Popover.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import '@tamagui/polyfill-dev'
2
2
 
3
- import type { UseHoverProps } from '@floating-ui/react'
3
+ import type { UseHoverProps } from '@tamagui/floating'
4
4
  import {
5
5
  Adapt,
6
6
  AdaptParent,
@@ -12,19 +12,20 @@ import {
12
12
  import { Animate } from '@tamagui/animate'
13
13
  import { ResetPresence } from '@tamagui/animate-presence'
14
14
  import { useComposedRefs } from '@tamagui/compose-refs'
15
- import { isWeb } from '@tamagui/constants'
16
- import type { SizeTokens, ViewProps, TamaguiElement } from '@tamagui/core'
15
+ import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
16
+ import type { SizeTokens, TamaguiElement, ViewProps } from '@tamagui/core'
17
17
  import {
18
18
  createStyledContext,
19
- styled,
20
- Theme,
21
19
  useCreateShallowSetState,
22
20
  useEvent,
23
21
  useGet,
24
- useThemeName,
25
22
  View,
26
23
  } from '@tamagui/core'
27
- import type { DismissableProps } from '@tamagui/dismissable'
24
+ import {
25
+ Dismissable,
26
+ DismissableBranch,
27
+ type DismissableProps,
28
+ } from '@tamagui/dismissable'
28
29
  import { FloatingOverrideContext } from '@tamagui/floating'
29
30
  import type { FocusScopeProps } from '@tamagui/focus-scope'
30
31
  import { FocusScope, FocusScopeController } from '@tamagui/focus-scope'
@@ -43,14 +44,13 @@ import {
43
44
  PopperProvider,
44
45
  usePopperContext,
45
46
  } from '@tamagui/popper'
46
- import { needsPortalRepropagation, Portal, resolveViewZIndex } from '@tamagui/portal'
47
+ import { needsPortalRepropagation, Portal } from '@tamagui/portal'
47
48
  import { RemoveScroll } from '@tamagui/remove-scroll'
48
49
  import { ScrollView, type ScrollViewProps } from '@tamagui/scroll-view'
49
50
  import { SheetController } from '@tamagui/sheet/controller'
50
51
  import type { YStackProps } from '@tamagui/stacks'
51
52
  import { YStack } from '@tamagui/stacks'
52
53
  import { useControllableState } from '@tamagui/use-controllable-state'
53
- import { StackZIndexContext } from '@tamagui/z-index-stack'
54
54
  import * as React from 'react'
55
55
  import { useFloatingContext } from './useFloatingContext'
56
56
 
@@ -62,6 +62,28 @@ type ScopedPopoverProps<P> = Omit<P, 'scope'> & {
62
62
 
63
63
  const needsRepropagation = needsPortalRepropagation()
64
64
 
65
+ const openPopovers = new Set<React.Dispatch<React.SetStateAction<boolean>>>()
66
+
67
+ export const hasOpenPopovers = () => {
68
+ return openPopovers.size > 0
69
+ }
70
+
71
+ export const closeOpenPopovers = () => {
72
+ if (openPopovers.size === 0) return false
73
+ openPopovers.forEach((setOpen) => setOpen(false))
74
+ return true
75
+ }
76
+
77
+ export const closeLastOpenedPopover = () => {
78
+ if (openPopovers.size === 0) return false
79
+ const last = Array.from(openPopovers).pop()
80
+ if (last) {
81
+ last(false)
82
+ return true
83
+ }
84
+ return false
85
+ }
86
+
65
87
  type PopoverVia = 'hover' | 'press'
66
88
 
67
89
  export type PopoverProps = ScopedPopoverProps<PopperProps> & {
@@ -86,6 +108,24 @@ export type PopoverProps = ScopedPopoverProps<PopperProps> & {
86
108
  * Disable focusing behavior on open
87
109
  */
88
110
  disableFocus?: boolean
111
+
112
+ /**
113
+ * Disable the dismissable layer (escape key, outside click handling).
114
+ * Useful for popovers that stay mounted but are visually hidden.
115
+ */
116
+ disableDismissable?: boolean
117
+
118
+ /**
119
+ * z-index for the popover portal. Use this when popovers need to appear
120
+ * above other portaled content like dialogs or fixed headers.
121
+ *
122
+ * By default, Tamagui automatically stacks overlays - later-opened content
123
+ * appears above earlier content, and nested content appears above its parent.
124
+ * Only set this if you need to override the automatic stacking behavior.
125
+ *
126
+ * @see https://tamagui.dev/ui/z-index
127
+ */
128
+ zIndex?: number
89
129
  }
90
130
 
91
131
  // let users override for type safety
@@ -106,7 +146,24 @@ type PopoverContextValue = {
106
146
  size?: SizeTokens
107
147
  breakpointActive?: boolean
108
148
  keepChildrenMounted?: boolean | 'lazy'
149
+ disableDismissable?: boolean
150
+ hoverable?: boolean | object
151
+ anchorTo?: Rect
152
+ // scoped branches Set for DismissableBranch/Dismissable to share
153
+ branches: Set<HTMLElement>
154
+ }
155
+
156
+ type PopoverTriggerStateSetter = React.Dispatch<React.SetStateAction<boolean>>
157
+
158
+ type PopoverTriggerContextValue = {
159
+ triggerRef: React.RefObject<any>
160
+ hasCustomAnchor: boolean
109
161
  anchorTo?: Rect
162
+ branches: Set<HTMLElement>
163
+ onOpenToggle(): void
164
+ setActiveTrigger(id: string | null): void
165
+ registerTrigger(id: string, setOpen: PopoverTriggerStateSetter): void
166
+ unregisterTrigger(id: string): void
110
167
  }
111
168
 
112
169
  export const PopoverContext = createStyledContext<PopoverContextValue>(
@@ -115,93 +172,310 @@ export const PopoverContext = createStyledContext<PopoverContextValue>(
115
172
  'Popover__'
116
173
  )
117
174
 
175
+ // zIndex flows from root Popover prop to PopoverContent portal
176
+ export const PopoverZIndexContext = React.createContext<number | undefined>(undefined)
177
+
178
+ // when adapted to a Sheet, tracks whether the sheet has finished sliding out.
179
+ // PopoverSheetController flips this true via SheetController.onAnimationComplete
180
+ // so PopoverContent can hold its adapted children mounted until the slide-out
181
+ // is done, instead of unmounting them on the popup's (passThrough) exit.
182
+ // defaults true (= safe to unmount) for any PopoverContent rendered outside a
183
+ // PopoverSheetController.
184
+ const PopoverAdaptHiddenContext = React.createContext(true)
185
+
186
+ export const PopoverTriggerContext = createStyledContext<PopoverTriggerContextValue>(
187
+ {} as PopoverTriggerContextValue,
188
+ 'PopoverTrigger__'
189
+ )
190
+
118
191
  export const usePopoverContext = PopoverContext.useStyledContext
192
+ export const usePopoverTriggerContext = PopoverTriggerContext.useStyledContext
119
193
 
120
- /* -------------------------------------------------------------------------------------------------
121
- * PopoverAnchor
122
- * -----------------------------------------------------------------------------------------------*/
194
+ /**
195
+ * Read reactive popover open state from the popover context.
196
+ */
197
+ export function usePopoverOpen(scope?: string): boolean {
198
+ return usePopoverContext(scope).open
199
+ }
123
200
 
124
- export type PopoverAnchorProps = ScopedPopoverProps<YStackProps>
201
+ /**
202
+ * Hook to set up trigger registration/isolation logic.
203
+ * Used internally by Popover and can be used by Tooltip.
204
+ */
205
+ export function usePopoverTriggerSetup(open: boolean) {
206
+ const triggerStateSettersRef = React.useRef(
207
+ new Map<string, PopoverTriggerStateSetter>()
208
+ )
209
+ const activeTriggerIdRef = React.useRef<string | null>(null)
125
210
 
126
- export const PopoverAnchor = React.forwardRef<TamaguiElement, PopoverAnchorProps>(
127
- function PopoverAnchor(props, forwardedRef) {
128
- const { scope, ...rest } = props
129
- const context = usePopoverContext(scope)
130
- const { onCustomAnchorAdd, onCustomAnchorRemove } = context || {}
211
+ const setActiveTrigger = useEvent((id: string | null) => {
212
+ const prevId = activeTriggerIdRef.current
213
+ if (prevId === id) return
214
+ if (prevId) {
215
+ triggerStateSettersRef.current.get(prevId)?.(false)
216
+ }
217
+ activeTriggerIdRef.current = id
218
+ if (id && open) {
219
+ triggerStateSettersRef.current.get(id)?.(true)
220
+ }
221
+ })
222
+
223
+ const registerTrigger = useEvent(
224
+ (id: string, setOpenState: PopoverTriggerStateSetter) => {
225
+ triggerStateSettersRef.current.set(id, setOpenState)
226
+ setOpenState(activeTriggerIdRef.current === id && open)
227
+ }
228
+ )
229
+
230
+ const unregisterTrigger = useEvent((id: string) => {
231
+ triggerStateSettersRef.current.delete(id)
232
+ if (activeTriggerIdRef.current === id) {
233
+ activeTriggerIdRef.current = null
234
+ }
235
+ })
236
+
237
+ React.useEffect(() => {
238
+ if (!open) {
239
+ setActiveTrigger(null)
240
+ return
241
+ }
242
+ const activeId = activeTriggerIdRef.current
243
+ if (activeId) {
244
+ triggerStateSettersRef.current.get(activeId)?.(true)
245
+ }
246
+ }, [open, setActiveTrigger])
247
+
248
+ return { setActiveTrigger, registerTrigger, unregisterTrigger }
249
+ }
250
+
251
+ export type PopoverContextProviderProps = {
252
+ scope: string
253
+ children: React.ReactNode
254
+ // PopoverContext values
255
+ open: boolean
256
+ onOpenChange(open: boolean, via?: 'hover' | 'press'): void
257
+ onOpenToggle(): void
258
+ triggerRef: React.RefObject<any>
259
+ id?: string
260
+ contentId?: string
261
+ hasCustomAnchor?: boolean
262
+ onCustomAnchorAdd?: () => void
263
+ onCustomAnchorRemove?: () => void
264
+ anchorTo?: Rect
265
+ // extra props for Popover (optional for Tooltip)
266
+ adaptScope?: string
267
+ breakpointActive?: boolean
268
+ keepChildrenMounted?: boolean | 'lazy'
269
+ disableDismissable?: boolean
270
+ hoverable?: boolean | object
271
+ }
131
272
 
132
- React.useEffect(() => {
133
- onCustomAnchorAdd()
134
- return () => onCustomAnchorRemove()
135
- }, [onCustomAnchorAdd, onCustomAnchorRemove])
273
+ /**
274
+ * Provider that sets up both PopoverContext and PopoverTriggerContext.
275
+ * Use this in Tooltip or other components that need popover trigger behavior.
276
+ */
277
+ export const PopoverContextProvider = React.memo(
278
+ ({
279
+ scope,
280
+ children,
281
+ open,
282
+ onOpenChange,
283
+ onOpenToggle,
284
+ triggerRef,
285
+ id = '',
286
+ contentId,
287
+ hasCustomAnchor = false,
288
+ onCustomAnchorAdd = voidFn,
289
+ onCustomAnchorRemove = voidFn,
290
+ anchorTo,
291
+ adaptScope,
292
+ breakpointActive,
293
+ keepChildrenMounted,
294
+ disableDismissable,
295
+ hoverable,
296
+ }: PopoverContextProviderProps) => {
297
+ const [branches] = React.useState(() => new Set<HTMLElement>())
298
+ const { setActiveTrigger, registerTrigger, unregisterTrigger } =
299
+ usePopoverTriggerSetup(open)
136
300
 
137
- return <PopperAnchor scope={scope} {...rest} ref={forwardedRef} />
301
+ return (
302
+ <PopoverContext.Provider
303
+ scope={scope}
304
+ popoverScope={scope}
305
+ adaptScope={adaptScope}
306
+ id={id}
307
+ contentId={contentId}
308
+ triggerRef={triggerRef}
309
+ open={open}
310
+ onOpenChange={onOpenChange}
311
+ onOpenToggle={onOpenToggle}
312
+ hasCustomAnchor={hasCustomAnchor}
313
+ onCustomAnchorAdd={onCustomAnchorAdd}
314
+ onCustomAnchorRemove={onCustomAnchorRemove}
315
+ anchorTo={anchorTo}
316
+ branches={branches}
317
+ breakpointActive={breakpointActive}
318
+ keepChildrenMounted={keepChildrenMounted}
319
+ disableDismissable={disableDismissable}
320
+ hoverable={hoverable}
321
+ >
322
+ <PopoverTriggerContext.Provider
323
+ scope={scope}
324
+ triggerRef={triggerRef}
325
+ hasCustomAnchor={hasCustomAnchor}
326
+ anchorTo={anchorTo}
327
+ branches={branches}
328
+ onOpenToggle={onOpenToggle}
329
+ setActiveTrigger={setActiveTrigger}
330
+ registerTrigger={registerTrigger}
331
+ unregisterTrigger={unregisterTrigger}
332
+ >
333
+ {children}
334
+ </PopoverTriggerContext.Provider>
335
+ </PopoverContext.Provider>
336
+ )
138
337
  }
139
338
  )
140
339
 
340
+ const voidFn = () => {}
341
+
141
342
  /* -------------------------------------------------------------------------------------------------
142
- * PopoverTrigger
343
+ * PopoverAnchor
143
344
  * -----------------------------------------------------------------------------------------------*/
144
345
 
145
- export type PopoverTriggerProps = ScopedPopoverProps<ViewProps>
346
+ export type PopoverAnchorProps = ScopedPopoverProps<YStackProps>
146
347
 
147
- export const PopoverTrigger = React.forwardRef<TamaguiElement, PopoverTriggerProps>(
148
- function PopoverTrigger(props, forwardedRef) {
149
- const { scope, ...rest } = props
150
- const context = usePopoverContext(scope)
348
+ export const PopoverAnchor = React.memo(
349
+ React.forwardRef<TamaguiElement, PopoverAnchorProps>(
350
+ function PopoverAnchor(props, forwardedRef) {
351
+ const { scope, ...rest } = props
352
+ const context = usePopoverContext(scope)
353
+ const { onCustomAnchorAdd, onCustomAnchorRemove } = context || {}
151
354
 
152
- const anchorTo = context.anchorTo
153
- const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef)
355
+ React.useEffect(() => {
356
+ onCustomAnchorAdd()
357
+ return () => onCustomAnchorRemove()
358
+ }, [onCustomAnchorAdd, onCustomAnchorRemove])
154
359
 
155
- if (!props.children) {
156
- return null
360
+ return <PopperAnchor scope={scope} {...rest} ref={forwardedRef} />
157
361
  }
362
+ )
363
+ )
158
364
 
159
- const trigger = (
160
- <View
161
- aria-expanded={context.open}
162
- // TODO not matching
163
- // aria-controls={context.contentId}
164
- data-state={getState(context.open)}
165
- {...rest}
166
- // @ts-ignore
167
- ref={composedTriggerRef}
168
- onPress={composeEventHandlers(props.onPress as any, context.onOpenToggle)}
169
- />
170
- )
365
+ /* -------------------------------------------------------------------------------------------------
366
+ * PopoverTrigger
367
+ * -----------------------------------------------------------------------------------------------*/
171
368
 
172
- const virtualRef = React.useMemo(() => {
173
- if (!anchorTo) {
369
+ export type PopoverTriggerProps = ScopedPopoverProps<
370
+ ViewProps & {
371
+ /**
372
+ * When true, disables the built-in click-to-toggle behavior on the trigger.
373
+ * Useful for hoverable popovers where you want to control open/close
374
+ * entirely through hover or your own handlers.
375
+ */
376
+ disablePressTrigger?: boolean
377
+ }
378
+ >
379
+
380
+ export const PopoverTrigger = React.memo(
381
+ React.forwardRef<TamaguiElement, PopoverTriggerProps>(
382
+ function PopoverTrigger(props, forwardedRef) {
383
+ const { scope, disablePressTrigger, ...rest } = props
384
+ const triggerContext = usePopoverTriggerContext(scope)
385
+ const triggerId = React.useId()
386
+ const [open, setOpen] = React.useState(false)
387
+ const anchorTo = triggerContext.anchorTo
388
+ const triggerElRef = React.useRef<TamaguiElement>(null)
389
+ const composedTriggerRef = useComposedRefs(forwardedRef, triggerElRef)
390
+
391
+ const { registerTrigger, unregisterTrigger } = triggerContext
392
+ React.useEffect(() => {
393
+ registerTrigger(triggerId, setOpen)
394
+ return () => {
395
+ unregisterTrigger(triggerId)
396
+ }
397
+ }, [registerTrigger, unregisterTrigger, triggerId])
398
+
399
+ if (!rest.children) {
174
400
  return null
175
401
  }
176
- return {
177
- current: {
178
- getBoundingClientRect: () => (isWeb ? DOMRect.fromRect(anchorTo) : anchorTo),
179
- ...(!isWeb && {
180
- measure: (c) =>
181
- c(anchorTo?.x, anchorTo?.y, anchorTo?.width, anchorTo?.height),
182
- measureInWindow: (c) =>
183
- c(anchorTo?.x, anchorTo?.y, anchorTo?.width, anchorTo?.height),
184
- }),
185
- },
402
+
403
+ const activateSelf = () => {
404
+ triggerContext.setActiveTrigger(triggerId)
405
+ const el = triggerElRef.current
406
+ if (el) {
407
+ triggerContext.triggerRef.current = el
408
+ }
186
409
  }
187
- }, [context.anchorTo, anchorTo?.x, anchorTo?.y, anchorTo?.height, anchorTo?.width])
188
-
189
- return context.hasCustomAnchor ? (
190
- trigger
191
- ) : (
192
- <PopperAnchor {...(virtualRef && { virtualRef })} scope={scope} asChild>
193
- {trigger}
194
- </PopperAnchor>
195
- )
196
- }
410
+
411
+ const trigger = (
412
+ <View
413
+ aria-expanded={open}
414
+ // TODO not matching
415
+ // aria-controls={context.contentId}
416
+ data-state={getState(open)}
417
+ {...rest}
418
+ // @ts-ignore
419
+ ref={composedTriggerRef}
420
+ onPress={composeEventHandlers(rest.onPress as any, () => {
421
+ if (disablePressTrigger) return
422
+ triggerContext.setActiveTrigger(open ? null : triggerId)
423
+ triggerContext.onOpenToggle()
424
+ })}
425
+ onMouseEnter={composeEventHandlers(rest.onMouseEnter as any, activateSelf)}
426
+ onPressIn={composeEventHandlers(rest.onPressIn as any, activateSelf)}
427
+ onFocus={composeEventHandlers(rest.onFocus as any, activateSelf)}
428
+ />
429
+ )
430
+
431
+ const virtualRef = React.useMemo(() => {
432
+ if (!anchorTo) {
433
+ return null
434
+ }
435
+ return {
436
+ current: {
437
+ getBoundingClientRect: () => (isWeb ? DOMRect.fromRect(anchorTo) : anchorTo),
438
+ ...(!isWeb && {
439
+ measure: (c) =>
440
+ c(anchorTo?.x, anchorTo?.y, anchorTo?.width, anchorTo?.height),
441
+ measureInWindow: (c) =>
442
+ c(anchorTo?.x, anchorTo?.y, anchorTo?.width, anchorTo?.height),
443
+ }),
444
+ },
445
+ }
446
+ }, [
447
+ triggerContext.anchorTo,
448
+ anchorTo?.x,
449
+ anchorTo?.y,
450
+ anchorTo?.height,
451
+ anchorTo?.width,
452
+ ])
453
+
454
+ // wrap trigger in DismissableBranch so clicking it doesn't fire pointerDownOutside
455
+ // which would close the popover before onPress can toggle it
456
+ const wrappedTrigger = isWeb ? (
457
+ <DismissableBranch branches={triggerContext.branches}>
458
+ {trigger}
459
+ </DismissableBranch>
460
+ ) : (
461
+ trigger
462
+ )
463
+
464
+ return triggerContext.hasCustomAnchor ? (
465
+ wrappedTrigger
466
+ ) : (
467
+ <PopperAnchor {...(virtualRef && { virtualRef })} scope={scope} asChild>
468
+ {wrappedTrigger}
469
+ </PopperAnchor>
470
+ )
471
+ }
472
+ )
197
473
  )
198
474
 
199
475
  /* -------------------------------------------------------------------------------------------------
200
476
  * PopoverContent
201
477
  * -----------------------------------------------------------------------------------------------*/
202
478
 
203
- type PopoverContentTypeElement = PopoverContentImplElement
204
-
205
479
  export interface PopoverContentTypeProps extends Omit<
206
480
  PopoverContentImplProps,
207
481
  'disableOutsidePointerEvents'
@@ -216,38 +490,49 @@ export interface PopoverContentTypeProps extends Omit<
216
490
 
217
491
  export type PopoverContentProps = PopoverContentTypeProps
218
492
 
219
- const PopoverContentFrame = styled(PopperContentFrame, {
220
- name: 'Popover',
221
- })
222
-
223
- export const PopoverContent = PopoverContentFrame.styleable<PopoverContentProps>(
493
+ export const PopoverContent = PopperContentFrame.styleable<PopoverContentProps>(
224
494
  function PopoverContent(props, forwardedRef) {
225
495
  const {
226
496
  trapFocus,
227
497
  enableRemoveScroll = false,
228
- zIndex,
498
+ zIndex: zIndexProp,
229
499
  scope,
230
500
  ...contentImplProps
231
501
  } = props
232
502
 
233
503
  const context = usePopoverContext(scope)
504
+ const zIndexFromContext = React.useContext(PopoverZIndexContext)
505
+ // prop on Content takes precedence for backwards compatibility, then context from root
506
+ const zIndex = zIndexProp ?? zIndexFromContext
507
+ const open = usePopoverOpen(scope)
234
508
  const contentRef = React.useRef<any>(null)
235
509
  const composedRefs = useComposedRefs(forwardedRef, contentRef)
236
510
  const isRightClickOutsideRef = React.useRef(false)
237
- const [isFullyHidden, setIsFullyHidden] = React.useState(!context.open)
511
+ const [isFullyHidden, setIsFullyHidden] = React.useState(!open)
238
512
 
239
513
  // Reset isFullyHidden when popover opens (useEffect avoids render-phase timing issues)
240
514
  // there was a hard to isolate bug in tamagui.dev where moving between /ui docs pages quickly
241
515
  // caused it to infinite loop, the setState in render (and useLayoutEffect) made it too prone
242
516
  // to bug, useEffect maybe fine here because its hidden, ok to be slightly delayed while hidden
243
- React.useEffect(() => {
244
- if (context.open && isFullyHidden) {
517
+ useIsomorphicLayoutEffect(() => {
518
+ if (open && isFullyHidden) {
245
519
  setIsFullyHidden(false)
246
520
  }
247
- }, [context.open, isFullyHidden])
248
-
521
+ }, [open, isFullyHidden])
522
+
523
+ // when adapted to a Sheet, the content is portaled into the sheet via
524
+ // Adapt.Contents. its mount lifecycle must follow the sheet's slide-out
525
+ // (PopoverAdaptHiddenContext, flipped by SheetController.onAnimationComplete),
526
+ // NOT the popup's own exit animation (isFullyHidden); the popup runs in
527
+ // passThrough mode while adapted, so isFullyHidden either fires immediately
528
+ // (content vanishes mid-slide) or never (content leaks), depending on driver.
529
+ const isAdaptFullyHidden = React.useContext(PopoverAdaptHiddenContext)
249
530
  if (!context.keepChildrenMounted) {
250
- if (isFullyHidden && !context.open) {
531
+ if (context.breakpointActive) {
532
+ if (!open && isAdaptFullyHidden) {
533
+ return null
534
+ }
535
+ } else if (isFullyHidden && !open) {
251
536
  return null
252
537
  }
253
538
  }
@@ -256,24 +541,24 @@ export const PopoverContent = PopoverContentFrame.styleable<PopoverContentProps>
256
541
  <PopoverPortal
257
542
  passThrough={context.breakpointActive}
258
543
  context={context}
544
+ open={open}
259
545
  zIndex={zIndex}
260
546
  >
261
547
  <View
262
548
  passThrough={context.breakpointActive}
263
- pointerEvents={
264
- context.open ? (contentImplProps.pointerEvents ?? 'auto') : 'none'
265
- }
549
+ pointerEvents={open ? (contentImplProps.pointerEvents ?? 'auto') : 'none'}
266
550
  >
267
551
  <PopoverContentImpl
268
552
  {...contentImplProps}
269
553
  context={context}
554
+ open={open}
270
555
  enableRemoveScroll={enableRemoveScroll}
271
556
  ref={composedRefs}
272
557
  setIsFullyHidden={setIsFullyHidden}
273
558
  scope={scope}
274
559
  // we make sure we're not trapping once it's been closed
275
560
  // (closed !== unmounted when animating out)
276
- trapFocus={trapFocus ?? context.open}
561
+ trapFocus={trapFocus ?? open}
277
562
  disableOutsidePointerEvents
278
563
  onCloseAutoFocus={
279
564
  props.onCloseAutoFocus === false
@@ -312,12 +597,14 @@ export const PopoverContent = PopoverContentFrame.styleable<PopoverContentProps>
312
597
 
313
598
  const useParentContexts = (scope: string) => {
314
599
  const context = usePopoverContext(scope)
600
+ const triggerContext = usePopoverTriggerContext(scope)
315
601
  const popperContext = usePopperContext(scope)
316
602
  const adaptContext = useAdaptContext(context.adaptScope)
317
603
  return {
318
604
  popperContext,
319
605
  adaptContext,
320
606
  context,
607
+ triggerContext,
321
608
  }
322
609
  }
323
610
 
@@ -327,14 +614,17 @@ function RepropagateParentContexts({
327
614
  adaptContext,
328
615
  children,
329
616
  context,
617
+ triggerContext,
330
618
  popperContext,
331
619
  }: ParentContexts & {
332
620
  children: React.ReactNode
333
621
  }) {
334
622
  return (
335
623
  <PopperProvider scope={context.popoverScope} {...popperContext}>
336
- <PopoverContext.Provider {...context}>
337
- <ProvideAdaptContext {...adaptContext}>{children}</ProvideAdaptContext>
624
+ <PopoverContext.Provider scope={context.popoverScope} {...context}>
625
+ <PopoverTriggerContext.Provider scope={context.popoverScope} {...triggerContext}>
626
+ <ProvideAdaptContext {...adaptContext}>{children}</ProvideAdaptContext>
627
+ </PopoverTriggerContext.Provider>
338
628
  </PopoverContext.Provider>
339
629
  </PopperProvider>
340
630
  )
@@ -347,6 +637,8 @@ const PortalAdaptSafe = ({
347
637
  children?: React.ReactNode
348
638
  context: PopoverContextValue
349
639
  }) => {
640
+ 'use no memo'
641
+
350
642
  if (needsRepropagation) {
351
643
  const parentContexts = useParentContexts(context.popoverScope)
352
644
  return (
@@ -363,42 +655,40 @@ const PortalAdaptSafe = ({
363
655
 
364
656
  function PopoverPortal({
365
657
  context,
658
+ open,
366
659
  zIndex,
367
660
  passThrough,
368
661
  children,
369
662
  onPress,
370
663
  }: Pick<PopoverContentProps, 'zIndex' | 'passThrough' | 'children' | 'onPress'> & {
371
664
  context: PopoverContextValue
665
+ open: boolean
372
666
  }) {
373
- const themeName = useThemeName()
667
+ 'use no memo'
374
668
 
375
669
  let content = children
376
670
 
377
- // native doesnt support portals
671
+ // native without teleport
378
672
  if (needsRepropagation) {
379
673
  const parentContexts = useParentContexts(context.popoverScope)
380
-
381
674
  content = (
382
675
  <RepropagateParentContexts {...parentContexts}>{content}</RepropagateParentContexts>
383
676
  )
384
677
  }
385
678
 
386
679
  return (
387
- <Portal passThrough={passThrough} stackZIndex zIndex={zIndex as any}>
680
+ <Portal passThrough={passThrough} stackZIndex zIndex={zIndex}>
388
681
  {/* forceClassName avoids forced re-mount renders for some reason... see the HeadMenu as you change tints a few times */}
389
682
  {/* without this you'll see the site menu re-rendering. It must be something in wrapping children in Theme */}
390
- <Theme passThrough={passThrough} contain forceClassName name={themeName}>
391
- {!!context.open && !context.breakpointActive && (
392
- <YStack
393
- fullscreen
394
- onPress={composeEventHandlers(onPress as any, context.onOpenToggle)}
395
- />
396
- )}
683
+ {!!open && !context.breakpointActive && !context.hoverable && (
684
+ <YStack
685
+ fullscreen
686
+ onPress={composeEventHandlers(onPress as any, context.onOpenToggle)}
687
+ />
688
+ )}
397
689
 
398
- <StackZIndexContext zIndex={resolveViewZIndex(zIndex)}>
399
- {content}
400
- </StackZIndexContext>
401
- </Theme>
690
+ {/* i removed a hardcoded StackZIndex because Portal has it internally now with useStackedZIndex + ZIndexHardcoded */}
691
+ {content}
402
692
  </Portal>
403
693
  )
404
694
  }
@@ -440,10 +730,20 @@ export type PopoverContentImplProps = PopperContentProps &
440
730
  enableRemoveScroll?: boolean
441
731
 
442
732
  freezeContentsWhenHidden?: boolean
733
+
734
+ /**
735
+ * Performance - if never going to use feature can permanently disable
736
+ */
737
+ alwaysDisable?: {
738
+ focus?: boolean
739
+ 'remove-scroll'?: boolean
740
+ dismiss?: boolean
741
+ }
443
742
  }
444
743
 
445
744
  type PopoverContentImplInteralProps = PopoverContentImplProps & {
446
745
  context: PopoverContextValue
746
+ open: boolean
447
747
  setIsFullyHidden: React.Dispatch<React.SetStateAction<boolean>>
448
748
  }
449
749
 
@@ -467,11 +767,14 @@ const PopoverContentImpl = React.forwardRef<
467
767
  freezeContentsWhenHidden,
468
768
  setIsFullyHidden,
469
769
  lazyMount,
770
+ forceUnmount,
470
771
  context,
772
+ open,
773
+ alwaysDisable,
471
774
  ...contentProps
472
775
  } = props
473
776
 
474
- const { open, keepChildrenMounted } = context
777
+ const { keepChildrenMounted, disableDismissable } = context
475
778
 
476
779
  const handleExitComplete = React.useCallback(() => {
477
780
  setIsFullyHidden?.(true)
@@ -481,14 +784,16 @@ const PopoverContentImpl = React.forwardRef<
481
784
  <ResetPresence disable={context.breakpointActive}>{children}</ResetPresence>
482
785
  )
483
786
 
787
+ const handleDismiss = React.useCallback(() => {
788
+ context.onOpenChange(false, 'press')
789
+ }, [context])
790
+
484
791
  // i want to avoid reparenting but react-remove-scroll makes it hard
485
792
  // TODO its removed now so we can probable do it now
486
793
  if (!context.breakpointActive) {
487
794
  if (process.env.TAMAGUI_TARGET !== 'native') {
488
- contents = (
489
- <RemoveScroll
490
- enabled={context.breakpointActive ? false : enableRemoveScroll ? open : false}
491
- >
795
+ if (!alwaysDisable || !alwaysDisable.focus) {
796
+ contents = (
492
797
  <FocusScope
493
798
  loop={trapFocus !== false}
494
799
  enabled={context.breakpointActive ? false : disableFocusScope ? false : open}
@@ -498,24 +803,36 @@ const PopoverContentImpl = React.forwardRef<
498
803
  >
499
804
  <div style={dspContentsStyle}>{contents}</div>
500
805
  </FocusScope>
501
- </RemoveScroll>
502
- )
503
- }
504
- }
806
+ )
807
+ }
505
808
 
506
- // const handleDismiss = React.useCallback((event: GestureResponderEvent) =>{
507
- // context.onOpenChange(false);
508
- // }, [])
509
- // <Dismissable
510
- // disableOutsidePointerEvents={disableOutsidePointerEvents}
511
- // // onInteractOutside={onInteractOutside}
512
- // onEscapeKeyDown={onEscapeKeyDown}
513
- // // onPointerDownOutside={onPointerDownOutside}
514
- // // onFocusOutside={onFocusOutside}
515
- // onDismiss={handleDismiss}
516
- // >
809
+ if (!alwaysDisable || !alwaysDisable['remove-scroll']) {
810
+ contents = (
811
+ <RemoveScroll
812
+ enabled={context.breakpointActive ? false : enableRemoveScroll ? open : false}
813
+ >
814
+ {contents}
815
+ </RemoveScroll>
816
+ )
817
+ }
517
818
 
518
- // const freeze = Boolean(isFullyHidden && freezeContentsWhenHidden)
819
+ if (!alwaysDisable || !alwaysDisable.dismiss) {
820
+ contents = (
821
+ <Dismissable
822
+ branches={context.branches}
823
+ forceUnmount={disableDismissable || (forceUnmount ?? !open)}
824
+ onEscapeKeyDown={onEscapeKeyDown}
825
+ onPointerDownOutside={onPointerDownOutside}
826
+ onFocusOutside={onFocusOutside}
827
+ onInteractOutside={onInteractOutside}
828
+ onDismiss={handleDismiss}
829
+ >
830
+ {contents}
831
+ </Dismissable>
832
+ )
833
+ }
834
+ }
835
+ }
519
836
 
520
837
  return (
521
838
  <Animate
@@ -533,6 +850,11 @@ const PopoverContentImpl = React.forwardRef<
533
850
  id={context.contentId}
534
851
  ref={forwardedRef}
535
852
  passThrough={context.breakpointActive}
853
+ {...(!contentProps.unstyled && {
854
+ size: '$true',
855
+ backgroundColor: '$background',
856
+ alignItems: 'center',
857
+ })}
536
858
  {...contentProps}
537
859
  >
538
860
  <PortalAdaptSafe context={context}>{contents}</PortalAdaptSafe>
@@ -683,6 +1005,8 @@ const PopoverInner = React.forwardRef<
683
1005
  keepChildrenMounted: keepChildrenMountedProp,
684
1006
  hoverable,
685
1007
  disableFocus,
1008
+ disableDismissable,
1009
+ zIndex,
686
1010
  id,
687
1011
  adaptScope,
688
1012
  ...restProps
@@ -691,11 +1015,13 @@ const PopoverInner = React.forwardRef<
691
1015
  const triggerRef = React.useRef<TamaguiElement>(null)
692
1016
  const [hasCustomAnchor, setHasCustomAnchor] = React.useState(false)
693
1017
  const viaRef = React.useRef<PopoverVia>(undefined)
1018
+
694
1019
  const [keepChildrenMounted] = useControllableState({
695
1020
  prop: keepChildrenMountedProp,
696
1021
  defaultProp: false,
697
1022
  transition: keepChildrenMountedProp === 'lazy',
698
1023
  })
1024
+
699
1025
  const [open, setOpen] = useControllableState({
700
1026
  prop: openProp,
701
1027
  defaultProp: defaultOpen || false,
@@ -704,6 +1030,15 @@ const PopoverInner = React.forwardRef<
704
1030
  },
705
1031
  })
706
1032
 
1033
+ // track open popovers for closeOpenPopovers()
1034
+ React.useEffect(() => {
1035
+ if (!open) return
1036
+ openPopovers.add(setOpen)
1037
+ return () => {
1038
+ openPopovers.delete(setOpen)
1039
+ }
1040
+ }, [open, setOpen])
1041
+
707
1042
  const handleOpenChange = useEvent((val, via) => {
708
1043
  viaRef.current = via
709
1044
  setOpen(val)
@@ -717,7 +1052,7 @@ const PopoverInner = React.forwardRef<
717
1052
  disable: isAdapted,
718
1053
  hoverable,
719
1054
  disableFocus: disableFocus,
720
- }) as any
1055
+ })
721
1056
 
722
1057
  const [anchorTo, setAnchorToRaw] = React.useState<Rect>()
723
1058
 
@@ -733,56 +1068,46 @@ const PopoverInner = React.forwardRef<
733
1068
  setOpen,
734
1069
  }))
735
1070
 
736
- // needs to be entirely memoized!
737
- const popoverContext = {
738
- popoverScope: scope,
739
- adaptScope,
740
- id,
741
- contentId: React.useId(),
742
- triggerRef,
743
- open,
744
- breakpointActive: isAdapted,
745
- onOpenChange: handleOpenChange,
746
- onOpenToggle: useEvent(() => {
747
- if (open && isAdapted) {
748
- return
749
- }
750
- setOpen(!open)
751
- }),
752
- hasCustomAnchor,
753
- anchorTo,
754
- onCustomAnchorAdd: React.useCallback(() => setHasCustomAnchor(true), []),
755
- onCustomAnchorRemove: React.useCallback(() => setHasCustomAnchor(false), []),
756
- keepChildrenMounted,
757
- } satisfies PopoverContextValue
758
-
759
- // // debug if changing too often
760
- // if (process.env.NODE_ENV === 'development') {
761
- // Object.keys(popoverContext).forEach((key) => {
762
- // React.useEffect(
763
- // () => console.log(`changed`, key, popoverContext[key]),
764
- // [popoverContext[key]]
765
- // )
766
- // })
767
- // }
768
-
769
- const memoizedChildren = React.useMemo(() => {
770
- return (
771
- <PopoverContext.Provider scope={scope} {...popoverContext}>
772
- <PopoverSheetController context={popoverContext} onOpenChange={setOpen}>
773
- {children}
774
- </PopoverSheetController>
775
- </PopoverContext.Provider>
776
- )
777
- }, [scope, setOpen, children, ...Object.values(popoverContext)])
1071
+ const contentId = React.useId()
1072
+
1073
+ const onOpenToggle = useEvent(() => {
1074
+ if (open && isAdapted) {
1075
+ return
1076
+ }
1077
+ setOpen(!open)
1078
+ })
1079
+
1080
+ const onCustomAnchorAdd = React.useCallback(() => setHasCustomAnchor(true), [])
1081
+ const onCustomAnchorRemove = React.useCallback(() => setHasCustomAnchor(false), [])
778
1082
 
779
1083
  const contents = (
780
1084
  <Popper open={open} passThrough={isAdapted} scope={scope} stayInFrame {...restProps}>
781
- {memoizedChildren}
1085
+ <PopoverContextProvider
1086
+ scope={scope}
1087
+ open={open}
1088
+ onOpenChange={handleOpenChange}
1089
+ onOpenToggle={onOpenToggle}
1090
+ triggerRef={triggerRef}
1091
+ id={id}
1092
+ contentId={contentId}
1093
+ hasCustomAnchor={hasCustomAnchor}
1094
+ onCustomAnchorAdd={onCustomAnchorAdd}
1095
+ onCustomAnchorRemove={onCustomAnchorRemove}
1096
+ anchorTo={anchorTo}
1097
+ adaptScope={adaptScope}
1098
+ breakpointActive={isAdapted}
1099
+ keepChildrenMounted={keepChildrenMounted}
1100
+ disableDismissable={disableDismissable}
1101
+ hoverable={hoverable}
1102
+ >
1103
+ <PopoverSheetController onOpenChange={setOpen} open={open} scope={scope}>
1104
+ {children}
1105
+ </PopoverSheetController>
1106
+ </PopoverContextProvider>
782
1107
  </Popper>
783
1108
  )
784
1109
 
785
- return (
1110
+ let result = (
786
1111
  <>
787
1112
  {isWeb ? (
788
1113
  <FloatingOverrideContext.Provider value={floatingContext}>
@@ -793,6 +1118,16 @@ const PopoverInner = React.forwardRef<
793
1118
  )}
794
1119
  </>
795
1120
  )
1121
+
1122
+ if (zIndex !== undefined) {
1123
+ return (
1124
+ <PopoverZIndexContext.Provider value={zIndex}>
1125
+ {result}
1126
+ </PopoverZIndexContext.Provider>
1127
+ )
1128
+ }
1129
+
1130
+ return result
796
1131
  })
797
1132
 
798
1133
  /* -----------------------------------------------------------------------------------------------*/
@@ -802,33 +1137,57 @@ function getState(open: boolean) {
802
1137
  }
803
1138
 
804
1139
  const PopoverSheetController = ({
805
- context,
1140
+ open,
1141
+ scope,
806
1142
  ...props
807
1143
  }: {
808
- context: PopoverContextValue
1144
+ open: boolean
1145
+ scope?: string
809
1146
  children: React.ReactNode
810
1147
  onOpenChange: React.Dispatch<React.SetStateAction<boolean>>
811
1148
  }) => {
812
- const showSheet = useShowPopoverSheet(context)
813
- const breakpointActive = context.breakpointActive
1149
+ const context = usePopoverContext(scope)
1150
+ const showSheet = useShowPopoverSheet(context, open)
1151
+ const breakpointActive = context?.breakpointActive
814
1152
  const getShowSheet = useGet(showSheet)
815
1153
 
1154
+ // tracks whether the adapted Sheet has finished its slide-out animation.
1155
+ // starts true (= safe to unmount) when closed; flips false the moment the
1156
+ // popover opens; flips back to true when the sheet signals onAnimationComplete
1157
+ // with open=false (slide-out finished). mirrors DialogSheetController.
1158
+ const [isAdaptFullyHidden, setIsAdaptFullyHidden] = React.useState(!open)
1159
+ if (open && isAdaptFullyHidden) {
1160
+ setIsAdaptFullyHidden(false)
1161
+ }
1162
+
1163
+ const handleSheetAnimationComplete = React.useCallback(
1164
+ ({ open: isOpen }: { open: boolean }) => {
1165
+ if (!isOpen) {
1166
+ setIsAdaptFullyHidden(true)
1167
+ }
1168
+ },
1169
+ []
1170
+ )
1171
+
816
1172
  return (
817
1173
  <SheetController
818
- onOpenChange={(val) => {
1174
+ onOpenChange={(val: boolean) => {
819
1175
  if (getShowSheet()) {
820
1176
  props.onOpenChange?.(val)
821
1177
  }
822
1178
  }}
823
- open={context.open}
1179
+ onAnimationComplete={handleSheetAnimationComplete}
1180
+ open={open}
824
1181
  hidden={!breakpointActive}
825
1182
  >
826
- {props.children}
1183
+ <PopoverAdaptHiddenContext.Provider value={isAdaptFullyHidden}>
1184
+ {props.children}
1185
+ </PopoverAdaptHiddenContext.Provider>
827
1186
  </SheetController>
828
1187
  )
829
1188
  }
830
1189
 
831
- const useShowPopoverSheet = (context: PopoverContextValue) => {
1190
+ const useShowPopoverSheet = (context: PopoverContextValue, open: boolean) => {
832
1191
  const isAdapted = useAdaptIsActive(context.adaptScope)
833
- return context.open === false ? false : isAdapted
1192
+ return open === false ? false : isAdapted
834
1193
  }