@tamagui/dialog 2.7.7 → 3.0.0-beta.643.1

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/Dialog.tsx CHANGED
@@ -9,9 +9,11 @@ import {
9
9
  import { Animate } from '@tamagui/animate'
10
10
  import { composeRefs, useComposedRefs } from '@tamagui/compose-refs'
11
11
  import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
12
- import type { GetProps, TamaguiElement, ViewProps } from '@tamagui/core'
12
+ import type { GetProps, OnTransition, TamaguiElement, ViewProps } from '@tamagui/core'
13
13
  import {
14
+ createStyledHOC,
14
15
  createStyledContext,
16
+ createRefComponent,
15
17
  getExpandedShorthand,
16
18
  LayoutMeasurementController,
17
19
  styled,
@@ -32,9 +34,8 @@ import {
32
34
  resolveViewZIndex,
33
35
  } from '@tamagui/portal'
34
36
  import { RemoveScroll } from '@tamagui/remove-scroll'
35
- import { SheetController } from '@tamagui/sheet/controller'
36
37
  import type { YStackProps } from '@tamagui/stacks'
37
- import { ButtonNestingContext, ThemeableStack, YStack } from '@tamagui/stacks'
38
+ import { ButtonNestingContext, YStack } from '@tamagui/stacks'
38
39
  import { H2, Paragraph } from '@tamagui/text'
39
40
  import { useControllableState } from '@tamagui/use-controllable-state'
40
41
  import { StackZIndexContext } from '@tamagui/z-index-stack'
@@ -69,21 +70,21 @@ type DialogProps = ScopedProps<{
69
70
  onAnimationComplete?: (info: { open: boolean }) => void
70
71
  }>
71
72
 
72
- type NonNull<A> = Exclude<A, void | null>
73
-
74
73
  type DialogContextValue = {
75
74
  forceMount?: boolean
76
75
  keepChildrenMounted?: boolean
77
76
  disableRemoveScroll?: boolean
77
+ hasPresentParts: boolean
78
+ setPartPresence(id: string, present: boolean): void
78
79
  triggerRef: React.RefObject<TamaguiElement | null>
79
80
  contentRef: React.RefObject<TamaguiElement | null>
80
81
  contentId: string
81
82
  titleId: string
82
83
  descriptionId: string
83
84
  onOpenToggle(): void
84
- open: NonNull<DialogProps['open']>
85
- onOpenChange: NonNull<DialogProps['onOpenChange']>
86
- modal: NonNull<DialogProps['modal']>
85
+ open: Exclude<DialogProps['open'], void | null>
86
+ onOpenChange: Exclude<DialogProps['onOpenChange'], void | null>
87
+ modal: Exclude<DialogProps['modal'], void | null>
87
88
  dialogScope: DialogScopes
88
89
  adaptScope: string
89
90
  onAnimationComplete?: DialogProps['onAnimationComplete']
@@ -91,43 +92,38 @@ type DialogContextValue = {
91
92
 
92
93
  export const DialogContext = createStyledContext<DialogContextValue>(
93
94
  // since we always provide this we can avoid setting here
94
- {} as DialogContextValue,
95
+ {},
95
96
  'Dialog__'
96
97
  )
97
98
 
98
99
  export const { useStyledContext: useDialogContext, Provider: DialogProvider } =
99
100
  DialogContext
100
101
 
101
- /**
102
- * Tracks whether the Sheet adapted from a Dialog has finished its slide-out
103
- * animation. DialogSheetController owns the state; DialogContent reads it so
104
- * it can hold adapted children mounted until the sheet is fully off-screen,
105
- * instead of tearing them down the moment Dialog.open flips false.
106
- *
107
- * Default true so the value is "safe to unmount" if no provider is mounted
108
- * (matches the historical behavior where adapted children unmount immediately
109
- * on close in native).
110
- */
111
- const DialogAdaptHiddenContext = React.createContext(true)
112
-
113
102
  /* -------------------------------------------------------------------------------------------------
114
103
  * DialogTrigger
115
104
  * -----------------------------------------------------------------------------------------------*/
116
105
 
117
106
  const DialogTriggerFrame = styled(View, {
118
- name: 'DialogTrigger',
107
+ displayName: 'DialogTrigger',
119
108
  })
120
109
 
121
110
  type DialogTriggerProps = ScopedProps<ViewProps>
122
111
 
123
- const DialogTrigger = DialogTriggerFrame.styleable<ScopedProps<{}>>(
124
- function DialogTrigger(props, forwardedRef) {
112
+ const DialogTrigger = createStyledHOC(
113
+ DialogTriggerFrame,
114
+ function DialogTrigger(props: DialogTriggerProps, forwardedRef) {
125
115
  const { scope, ...triggerProps } = props
126
116
  const isInsideButton = React.useContext(ButtonNestingContext)
127
117
  const context = useDialogContext(scope)
128
118
  const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef)
119
+ // only mark descendants as button-nested when this trigger renders its OWN
120
+ // <button>. with asChild the child element BECOMES the trigger (it receives
121
+ // our composed onPress directly), so it is the control, not a decorative
122
+ // button nested inside one - forcing nesting here would strip its
123
+ // interactivity (Button's nested branch neutralizes role/tabIndex/press).
124
+ // preserve whatever nesting the trigger itself sits in. matches Popover.Trigger.
129
125
  return (
130
- <ButtonNestingContext.Provider value={true}>
126
+ <ButtonNestingContext.Provider value={props.asChild ? isInsideButton : true}>
131
127
  <DialogTriggerFrame
132
128
  render={isInsideButton ? 'span' : 'button'}
133
129
  aria-haspopup="dialog"
@@ -159,37 +155,20 @@ type DialogPortalProps = ScopedProps<
159
155
 
160
156
  export const DialogPortalFrame = styled(YStack, {
161
157
  pointerEvents: 'none',
158
+ alignItems: 'center',
159
+ justifyContent: 'center',
160
+ position: 'absolute web:fixed',
161
+ inset: 0,
162
+ borderWidth: 'web:0px',
163
+ backgroundColor: 'web:transparent',
164
+ color: 'web:inherit',
165
+ maxInlineSize: 'web:none',
166
+ margin: 'web:0px',
167
+ width: 'web:auto',
168
+ height: 'web:auto',
169
+ maxHeight: 'web:100vh',
170
+ zIndex: 'web:1',
162
171
  render: 'dialog',
163
-
164
- variants: {
165
- unstyled: {
166
- false: {
167
- alignItems: 'center',
168
- justifyContent: 'center',
169
- fullscreen: true,
170
-
171
- '$platform-web': {
172
- // undo dialog styles
173
- borderWidth: 0,
174
- backgroundColor: 'transparent',
175
- color: 'inherit',
176
- maxInlineSize: 'none',
177
- margin: 0,
178
- width: 'auto',
179
- height: 'auto',
180
- // ensure always in frame and right height
181
- maxHeight: '100vh',
182
- position: 'fixed',
183
- // ensure dialog inherits stacking context from portal wrapper
184
- zIndex: 1,
185
- },
186
- },
187
- },
188
- } as const,
189
-
190
- defaultVariants: {
191
- unstyled: process.env.TAMAGUI_HEADLESS === '1',
192
- },
193
172
  })
194
173
 
195
174
  const needsRepropagation = needsPortalRepropagation()
@@ -218,34 +197,27 @@ const DialogPortalItem = ({
218
197
 
219
198
  // until we can use react-native portals natively
220
199
  // have to re-propogate context, sketch
221
- // when adapted we portal to the adapt, when not we portal to root modal if needed
200
+ // when adapted we publish to the Adapt live slot, otherwise we portal to root modal if needed
222
201
  return isAdapted ? (
223
202
  <AdaptPortalContents scope={context.adaptScope}>{content}</AdaptPortalContents>
224
203
  ) : context.modal ? (
225
- <PortalItem hostName={context.modal ? 'root' : context.adaptScope}>
226
- {content}
227
- </PortalItem>
204
+ <PortalItem hostName="root">{content}</PortalItem>
228
205
  ) : (
229
206
  content
230
207
  )
231
208
  }
232
209
 
233
- const DialogPortal = React.forwardRef<TamaguiElement, DialogPortalProps>(
234
- (props, forwardRef) => {
210
+ const DialogPortal = createRefComponent<TamaguiElement, DialogPortalProps>(
211
+ (props, forwardedRef) => {
235
212
  const { scope, forceMount, children, ...frameProps } = props
236
213
  const dialogRef = React.useRef<TamaguiElement>(null)
237
- const ref = composeRefs(dialogRef, forwardRef)
214
+ const ref = composeRefs(dialogRef, forwardedRef)
238
215
 
239
216
  const context = useDialogContext(scope)
217
+ const portalContext = forceMount ? { ...context, forceMount: true } : context
240
218
  const keepMounted = forceMount || context.keepChildrenMounted
241
219
  const isAdapted = useAdaptIsActive(context.adaptScope)
242
- const [isFullyHidden, setIsFullyHidden] = React.useState(!context.open)
243
-
244
- if (context.open && isFullyHidden) {
245
- setIsFullyHidden(false)
246
- }
247
-
248
- const isVisible = context.open ? true : !isFullyHidden
220
+ const isVisible = context.open || context.hasPresentParts
249
221
 
250
222
  if (isWeb) {
251
223
  useIsomorphicLayoutEffect(() => {
@@ -260,49 +232,27 @@ const DialogPortal = React.forwardRef<TamaguiElement, DialogPortalProps>(
260
232
  }, [isVisible])
261
233
  }
262
234
 
263
- const onAnimationCompleteRef = React.useRef(context.onAnimationComplete)
264
- onAnimationCompleteRef.current = context.onAnimationComplete
265
-
266
- const handleExitComplete = React.useCallback(() => {
267
- setIsFullyHidden(true)
268
- onAnimationCompleteRef.current?.({ open: false })
269
- }, [])
270
-
271
- React.useEffect(() => {
272
- if (context.open && !isAdapted && onAnimationCompleteRef.current) {
273
- const tm = setTimeout(() => {
274
- onAnimationCompleteRef.current?.({ open: true })
275
- }, 350)
276
- return () => clearTimeout(tm)
277
- }
278
- }, [context.open, isAdapted])
279
-
280
235
  const zIndex = getExpandedShorthand('zIndex', props)
281
236
 
282
237
  const contents = (
283
238
  <StackZIndexContext zIndex={resolveViewZIndex(zIndex)}>
284
- <Animate
285
- type="presence"
286
- present={Boolean(context.open)}
287
- keepChildrenMounted={Boolean(keepMounted)}
288
- onExitComplete={handleExitComplete}
289
- passThrough={isAdapted}
290
- >
239
+ <DialogProvider scope={context.dialogScope} {...portalContext}>
291
240
  {children}
292
- </Animate>
241
+ </DialogProvider>
293
242
  </StackZIndexContext>
294
243
  )
295
244
 
296
245
  const framedContents =
297
- isFullyHidden && !keepMounted && !isAdapted ? null : (
246
+ !isVisible && !keepMounted && !isAdapted ? null : (
298
247
  <LayoutMeasurementController disable={!context.open}>
299
248
  <DialogPortalFrame
300
249
  ref={ref}
301
250
  {...(isWeb &&
302
- context.open && {
251
+ context.open &&
252
+ context.modal && {
303
253
  'aria-modal': true,
304
254
  })}
305
- pointerEvents={context.open ? 'auto' : 'none'}
255
+ pointerEvents={context.open && context.modal ? 'auto' : 'none'}
306
256
  {...frameProps}
307
257
  className={`_no_backdrop ` + (frameProps.className || '')}
308
258
  >
@@ -319,6 +269,9 @@ const DialogPortal = React.forwardRef<TamaguiElement, DialogPortalProps>(
319
269
  // this makes sure its above typical stacking contexts
320
270
  stackZIndex={100000}
321
271
  passThrough={isAdapted}
272
+ // hide the host while closed (covers keepChildrenMounted content, see
273
+ // Portal.tsx); isVisible stays true through the exit animation
274
+ hidden={!isVisible}
322
275
  >
323
276
  <PassthroughTheme passThrough={isAdapted}>{framedContents}</PassthroughTheme>
324
277
  </Portal>
@@ -328,7 +281,7 @@ const DialogPortal = React.forwardRef<TamaguiElement, DialogPortalProps>(
328
281
  return isAdapted ? (
329
282
  framedContents
330
283
  ) : (
331
- <DialogPortalItem context={context}>{framedContents}</DialogPortalItem>
284
+ <DialogPortalItem context={portalContext}>{framedContents}</DialogPortalItem>
332
285
  )
333
286
  }
334
287
  )
@@ -349,18 +302,92 @@ const PassthroughTheme = ({
349
302
  )
350
303
  }
351
304
 
305
+ function useDialogAnimationReporter(context: DialogContextValue) {
306
+ const onAnimationCompleteRef = React.useRef(context.onAnimationComplete)
307
+ onAnimationCompleteRef.current = context.onAnimationComplete
308
+
309
+ const openRef = React.useRef(context.open)
310
+ const pendingTransitionRef = React.useRef<boolean | null>(context.open ? true : null)
311
+
312
+ if (openRef.current !== context.open) {
313
+ openRef.current = context.open
314
+ pendingTransitionRef.current = context.open
315
+ }
316
+
317
+ const reportComplete = React.useCallback((open: boolean) => {
318
+ if (pendingTransitionRef.current !== open) return
319
+ if (openRef.current !== open) return
320
+
321
+ pendingTransitionRef.current = null
322
+ onAnimationCompleteRef.current?.({ open })
323
+ }, [])
324
+
325
+ return React.useMemo(
326
+ () => ({
327
+ onEnterComplete: () => reportComplete(true),
328
+ onExitComplete: () => reportComplete(false),
329
+ }),
330
+ [reportComplete]
331
+ )
332
+ }
333
+
334
+ function useDialogPartPresence(
335
+ context: DialogContextValue,
336
+ options: {
337
+ disabled?: boolean
338
+ forceMount?: boolean
339
+ id: string
340
+ onExitComplete?: () => void
341
+ }
342
+ ) {
343
+ const [isFullyHidden, setIsFullyHidden] = React.useState(!context.open)
344
+ const reactId = React.useId()
345
+ const partPresenceId = `${context.contentId}-${options.id}-${reactId}`
346
+ const keepMounted = options.forceMount || context.keepChildrenMounted
347
+ const isPresent = context.open || !isFullyHidden
348
+
349
+ useIsomorphicLayoutEffect(() => {
350
+ if (context.open && isFullyHidden) {
351
+ setIsFullyHidden(false)
352
+ }
353
+ }, [context.open, isFullyHidden])
354
+
355
+ useIsomorphicLayoutEffect(() => {
356
+ if (options.disabled) return
357
+
358
+ context.setPartPresence(partPresenceId, isPresent)
359
+ return () => {
360
+ context.setPartPresence(partPresenceId, false)
361
+ }
362
+ }, [context.setPartPresence, isPresent, options.disabled, partPresenceId])
363
+
364
+ const onExitComplete = React.useCallback(() => {
365
+ setIsFullyHidden(true)
366
+ options.onExitComplete?.()
367
+ }, [options.onExitComplete])
368
+
369
+ return {
370
+ keepMounted,
371
+ onExitComplete,
372
+ shouldRender: Boolean(keepMounted || context.open || !isFullyHidden),
373
+ }
374
+ }
375
+
352
376
  /* -------------------------------------------------------------------------------------------------
353
377
  * DialogOverlay
354
378
  * -----------------------------------------------------------------------------------------------*/
355
379
 
356
380
  const OVERLAY_NAME = 'DialogOverlay'
357
381
 
358
- /**
359
- * exported for internal use with extractable()
360
- */
382
+ // Unstyled overlay frame: positioning + pointer-event bookkeeping only. The
383
+ // dim/scrim background lives in the tamagui skin
384
+ // (code/ui/tamagui/src/components/Dialog.tsx).
361
385
  export const DialogOverlayFrame = styled(YStack, {
362
- name: OVERLAY_NAME,
386
+ displayName: OVERLAY_NAME,
363
387
  zIndex: 1,
388
+ inset: 0,
389
+ position: 'absolute',
390
+ pointerEvents: 'auto',
364
391
 
365
392
  variants: {
366
393
  open: {
@@ -371,20 +398,7 @@ export const DialogOverlayFrame = styled(YStack, {
371
398
  pointerEvents: 'none',
372
399
  },
373
400
  },
374
-
375
- unstyled: {
376
- false: {
377
- fullscreen: true,
378
- position: 'absolute',
379
- backgroundColor: '$background',
380
- pointerEvents: 'auto',
381
- },
382
- },
383
401
  } as const,
384
-
385
- defaultVariants: {
386
- unstyled: process.env.TAMAGUI_HEADLESS === '1',
387
- },
388
402
  })
389
403
 
390
404
  export type DialogOverlayExtraProps = ScopedProps<{
@@ -397,33 +411,46 @@ export type DialogOverlayExtraProps = ScopedProps<{
397
411
 
398
412
  type DialogOverlayProps = YStackProps & DialogOverlayExtraProps
399
413
 
400
- const DialogOverlay = DialogOverlayFrame.styleable<DialogOverlayExtraProps>(
401
- function DialogOverlay({ scope, ...props }, forwardedRef) {
414
+ const DialogOverlay = createStyledHOC(
415
+ DialogOverlayFrame,
416
+ function DialogOverlay({ scope, ...props }: DialogOverlayProps, forwardedRef) {
402
417
  const context = useDialogContext(scope)
403
418
  const { forceMount = context.forceMount, ...overlayProps } = props
404
419
  const isAdapted = useAdaptIsActive(context.adaptScope)
420
+ const presence = useDialogPartPresence(context, {
421
+ disabled: isAdapted,
422
+ forceMount,
423
+ id: 'overlay',
424
+ })
405
425
 
406
- if (!forceMount) {
407
- if (!context.modal || isAdapted) {
408
- return null
409
- }
426
+ if (!forceMount && isAdapted) {
427
+ return null
428
+ }
429
+
430
+ if (!presence.shouldRender) {
431
+ return null
410
432
  }
411
433
 
412
434
  // Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
413
435
  // ie. when `Overlay` and `Content` are siblings
414
436
  return (
415
- <DialogOverlayFrame
416
- data-state={getState(context.open)}
417
- // TODO: this will be apply for v2
418
- // onPress={() => {
419
- // // if the overlay is pressed, close the dialog
420
- // context.onOpenChange(false)
421
- // }}
422
- // We re-enable pointer-events prevented by `Dialog.Content` to allow scrolling the overlay.
423
- pointerEvents={context.open ? 'auto' : 'none'}
424
- {...overlayProps}
425
- ref={forwardedRef}
426
- />
437
+ <Animate
438
+ type="presence"
439
+ present={Boolean(context.open)}
440
+ keepChildrenMounted={Boolean(presence.keepMounted)}
441
+ onExitComplete={presence.onExitComplete}
442
+ passThrough={isAdapted}
443
+ >
444
+ <DialogOverlayFrame
445
+ key={`${context.contentId}-overlay`}
446
+ data-state={getState(context.open)}
447
+ // Presence freezes the exiting clone with its open props. The exit
448
+ // clause releases the full-screen overlay during that frozen frame.
449
+ pointerEvents={context.open && context.modal ? 'auto exit:none' : 'none'}
450
+ {...overlayProps}
451
+ ref={forwardedRef}
452
+ />
453
+ </Animate>
427
454
  )
428
455
  }
429
456
  )
@@ -434,36 +461,34 @@ const DialogOverlay = DialogOverlayFrame.styleable<DialogOverlayExtraProps>(
434
461
 
435
462
  const CONTENT_NAME = 'DialogContent'
436
463
 
437
- const DialogContentFrame = styled(ThemeableStack, {
438
- name: CONTENT_NAME,
464
+ // Unstyled content frame: stacking + pointer events + the opt-in elevate/bordered
465
+ // v2-compat variants (formerly inherited from ThemeableStack, off by default).
466
+ // Background, padding, and radius live in the tamagui skin. The variants stay on
467
+ // this frame (not a skin wrapper) because the frame is the animated node — a
468
+ // variant block on a wrapper around it breaks the native animation driver's
469
+ // interpolation.
470
+ const DialogContentFrame = styled(YStack, {
471
+ displayName: CONTENT_NAME,
439
472
  zIndex: 2,
473
+ position: 'relative',
474
+ // Ensure content receives pointer events (fixes React 19 + display:contents inheritance)
475
+ pointerEvents: 'auto',
440
476
 
441
477
  variants: {
442
- size: {
443
- '...size': (val, extras) => {
444
- return {}
478
+ elevate: {
479
+ true: {
480
+ shadowColor: 'shadow-color',
481
+ shadowRadius: 24,
482
+ shadowOffset: { width: 0, height: 12 },
445
483
  },
446
484
  },
447
-
448
- unstyled: {
449
- false: {
450
- position: 'relative',
451
- backgroundColor: '$background',
485
+ bordered: {
486
+ true: {
452
487
  borderWidth: 1,
453
- borderColor: '$borderColor',
454
- padding: '$true',
455
- borderRadius: '$true',
456
- elevate: true,
457
- // Ensure content receives pointer events (fixes React 19 + display:contents inheritance)
458
- pointerEvents: 'auto',
488
+ borderColor: 'border-color',
459
489
  },
460
490
  },
461
491
  } as const,
462
-
463
- defaultVariants: {
464
- size: '$true',
465
- unstyled: process.env.TAMAGUI_HEADLESS === '1',
466
- },
467
492
  })
468
493
 
469
494
  type DialogContentFrameProps = GetProps<typeof DialogContentFrame>
@@ -474,28 +499,89 @@ type DialogContentExtraProps = ScopedProps<
474
499
 
475
500
  type DialogContentProps = DialogContentFrameProps & DialogContentExtraProps
476
501
 
477
- const DialogContent = DialogContentFrame.styleable<DialogContentExtraProps>(
478
- function DialogContent({ scope, ...props }, forwardedRef) {
502
+ const DialogContent = createStyledHOC(
503
+ DialogContentFrame,
504
+ function DialogContent({ scope, ...props }: DialogContentExtraProps, forwardedRef) {
479
505
  const context = useDialogContext(scope)
506
+ const isAdapted = useAdaptIsActive(context.adaptScope)
507
+ const reporter = useDialogAnimationReporter(context)
508
+ const onTransitionProp = props.onTransition
509
+ const onTransition = React.useCallback<OnTransition>(
510
+ (event) => {
511
+ if (event.phase === 'end' && event.cause === 'enter') {
512
+ reporter.onEnterComplete()
513
+ }
514
+ onTransitionProp?.(event)
515
+ },
516
+ [reporter.onEnterComplete, onTransitionProp]
517
+ )
518
+ const presence = useDialogPartPresence(context, {
519
+ disabled: isAdapted,
520
+ forceMount: context.forceMount,
521
+ id: 'content',
522
+ onExitComplete: reporter.onExitComplete,
523
+ })
480
524
 
481
525
  const contents = (
482
526
  <>
483
527
  {context.modal ? (
484
- <DialogContentModal context={context} {...props} ref={forwardedRef} />
528
+ <DialogContentModal
529
+ context={context}
530
+ {...props}
531
+ onTransition={onTransition}
532
+ ref={forwardedRef}
533
+ />
485
534
  ) : (
486
- <DialogContentNonModal context={context} {...props} ref={forwardedRef} />
535
+ <DialogContentNonModal
536
+ context={context}
537
+ {...props}
538
+ onTransition={onTransition}
539
+ ref={forwardedRef}
540
+ />
487
541
  )}
488
542
  </>
489
543
  )
490
544
 
545
+ if (isAdapted) {
546
+ if (!isWeb || context.disableRemoveScroll) {
547
+ return contents
548
+ }
549
+
550
+ return (
551
+ <RemoveScroll enabled={context.open && context.modal}>
552
+ <div data-remove-scroll-container className="_dsp_contents">
553
+ {contents}
554
+ </div>
555
+ </RemoveScroll>
556
+ )
557
+ }
558
+
559
+ if (!presence.shouldRender) {
560
+ return null
561
+ }
562
+
563
+ const animated = (
564
+ <Animate
565
+ type="presence"
566
+ present={Boolean(context.open)}
567
+ keepChildrenMounted={Boolean(presence.keepMounted)}
568
+ onExitComplete={presence.onExitComplete}
569
+ >
570
+ <React.Fragment key={context.contentId}>{contents}</React.Fragment>
571
+ </Animate>
572
+ )
573
+
491
574
  if (!isWeb || context.disableRemoveScroll) {
492
- return contents
575
+ return animated
493
576
  }
494
577
 
578
+ // RemoveScroll must stay OUTSIDE the presence boundary: the exiting clone
579
+ // freezes props, so an enabled={open} baked inside it would keep the body
580
+ // pointer-events lock for the whole exit animation
495
581
  return (
496
- <RemoveScroll enabled={context.open}>
582
+ <RemoveScroll enabled={context.open && context.modal}>
497
583
  <div data-remove-scroll-container className="_dsp_contents">
498
- {contents}
584
+ {animated}
499
585
  </div>
500
586
  </RemoveScroll>
501
587
  )
@@ -508,8 +594,13 @@ type DialogContentTypeProps = DialogContentImplProps & {
508
594
  context: DialogContextValue
509
595
  }
510
596
 
511
- const DialogContentModal = React.forwardRef<TamaguiElement, DialogContentTypeProps>(
512
- ({ children, context, ...props }, forwardedRef) => {
597
+ const DialogContentModal = createRefComponent<TamaguiElement, DialogContentTypeProps>(
598
+ ({ children, context: contextProp, ...props }, forwardedRef) => {
599
+ // re-read context via hook: this component renders inside the presence
600
+ // boundary, whose exiting clone freezes props at open=true — a hook
601
+ // subscription still receives fresh context so open-derived behavior
602
+ // (body pointer-events lock, focus trap) releases as soon as close starts
603
+ const context = useDialogContext(contextProp.dialogScope)
513
604
  const contentRef = React.useRef<TamaguiElement>(null)
514
605
  const composedRefs = useComposedRefs(forwardedRef, context.contentRef, contentRef)
515
606
 
@@ -523,29 +614,28 @@ const DialogContentModal = React.forwardRef<TamaguiElement, DialogContentTypePro
523
614
  trapFocus={context.open}
524
615
  disableOutsidePointerEvents
525
616
  onCloseAutoFocus={composeEventHandlers(props.onCloseAutoFocus, (event) => {
526
- event.preventDefault()
617
+ event.cancel()
527
618
  context.triggerRef.current?.focus()
528
619
  })}
529
620
  onPointerDownOutside={composeEventHandlers(
530
621
  props.onPointerDownOutside,
531
622
  (event) => {
532
- const originalEvent = event['detail'].originalEvent
623
+ const originalEvent = event.event
624
+ if (!originalEvent) return
533
625
  const ctrlLeftClick =
534
626
  originalEvent.button === 0 && originalEvent.ctrlKey === true
535
627
  const isRightClick = originalEvent.button === 2 || ctrlLeftClick
536
628
  // If the event is a right-click, we shouldn't close because
537
629
  // it is effectively as if we right-clicked the `Overlay`.
538
- if (isRightClick) event.preventDefault()
630
+ if (isRightClick) event.cancel()
539
631
  }
540
632
  )}
541
633
  // When focus is trapped, a `focusout` event may still happen.
542
634
  // We make sure we don't trigger our `onDismiss` in such case.
543
635
  onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) =>
544
- event.preventDefault()
636
+ event.cancel()
545
637
  )}
546
- {...(!props.unstyled && {
547
- outlineStyle: 'none',
548
- })}
638
+ outlineStyle="none"
549
639
  >
550
640
  {children}
551
641
  </DialogContentImpl>
@@ -555,25 +645,28 @@ const DialogContentModal = React.forwardRef<TamaguiElement, DialogContentTypePro
555
645
 
556
646
  /* -----------------------------------------------------------------------------------------------*/
557
647
 
558
- const DialogContentNonModal = React.forwardRef<TamaguiElement, DialogContentTypeProps>(
648
+ const DialogContentNonModal = createRefComponent<TamaguiElement, DialogContentTypeProps>(
559
649
  (props, forwardedRef) => {
650
+ // fresh context read for the same presence-freeze reason as DialogContentModal
651
+ const context = useDialogContext(props.context.dialogScope)
560
652
  const hasInteractedOutsideRef = React.useRef(false)
561
653
 
562
654
  return (
563
655
  <DialogContentImpl
564
656
  {...props}
657
+ context={context}
565
658
  ref={forwardedRef}
566
659
  trapFocus={false}
567
660
  disableOutsidePointerEvents={false}
568
661
  onCloseAutoFocus={(event) => {
569
662
  props.onCloseAutoFocus?.(event)
570
663
 
571
- if (!event.defaultPrevented) {
664
+ if (!event.isCanceled) {
572
665
  if (!hasInteractedOutsideRef.current) {
573
666
  props.context.triggerRef.current?.focus()
574
667
  }
575
668
  // Always prevent auto focus because we either focus manually or want user agent focus
576
- event.preventDefault()
669
+ event.cancel()
577
670
  }
578
671
 
579
672
  hasInteractedOutsideRef.current = false
@@ -581,7 +674,7 @@ const DialogContentNonModal = React.forwardRef<TamaguiElement, DialogContentType
581
674
  onInteractOutside={(event) => {
582
675
  props.onInteractOutside?.(event)
583
676
 
584
- if (!event.defaultPrevented) hasInteractedOutsideRef.current = true
677
+ if (!event.isCanceled) hasInteractedOutsideRef.current = true
585
678
 
586
679
  // Prevent dismissing when clicking the trigger.
587
680
  // As the trigger is already setup to close, without doing so would
@@ -589,11 +682,11 @@ const DialogContentNonModal = React.forwardRef<TamaguiElement, DialogContentType
589
682
  //
590
683
  // We use `onInteractOutside` as some browsers also
591
684
  // focus on pointer down, creating the same issue.
592
- const target = event.target as HTMLElement
685
+ const target = event.event?.target as HTMLElement | null
593
686
  const trigger = props.context.triggerRef.current
594
- if (!(trigger instanceof HTMLElement)) return
687
+ if (!target || !(trigger instanceof HTMLElement)) return
595
688
  const targetIsTrigger = trigger.contains(target)
596
- if (targetIsTrigger) event.preventDefault()
689
+ if (targetIsTrigger) event.cancel()
597
690
  }}
598
691
  />
599
692
  )
@@ -612,22 +705,23 @@ type DialogContentImplExtraProps = Omit<DismissableProps, 'onDismiss'> & {
612
705
 
613
706
  /**
614
707
  * Event handler called when auto-focusing on open.
615
- * Can be prevented.
708
+ * Can be canceled.
616
709
  */
617
710
  onOpenAutoFocus?: FocusScopeProps['onMountAutoFocus']
618
711
 
619
712
  /**
620
713
  * Event handler called when auto-focusing on close.
621
- * Can be prevented.
714
+ * Can be canceled.
622
715
  */
623
716
  onCloseAutoFocus?: FocusScopeProps['onUnmountAutoFocus']
624
717
 
625
718
  context: DialogContextValue
719
+ onTransition?: OnTransition
626
720
  }
627
721
 
628
722
  type DialogContentImplProps = DialogContentFrameProps & DialogContentImplExtraProps
629
723
 
630
- const DialogContentImpl = React.forwardRef<TamaguiElement, DialogContentImplProps>(
724
+ const DialogContentImpl = createRefComponent<TamaguiElement, DialogContentImplProps>(
631
725
  (props, forwardedRef) => {
632
726
  const {
633
727
  trapFocus,
@@ -639,24 +733,23 @@ const DialogContentImpl = React.forwardRef<TamaguiElement, DialogContentImplProp
639
733
  onFocusOutside,
640
734
  onInteractOutside,
641
735
  context,
736
+ onTransition,
642
737
  ...contentProps
643
738
  } = props
644
739
 
645
740
  const contentRef = React.useRef<TamaguiElement>(null)
646
741
  const composedRefs = useComposedRefs(forwardedRef, contentRef)
647
742
  const isAdapted = useAdaptIsActive(context.adaptScope)
743
+ const adaptContext = useAdaptContext(context.adaptScope)
648
744
 
649
745
  // TODO this will re-parent, ideally we would not change tree structure
650
746
 
651
- // when adapted, the dialog's content is portaled into the Sheet via
652
- // Adapt.Contents. hold children mounted until the sheet's slide-out
653
- // animation is fully complete (DialogAdaptHiddenContext is flipped by
654
- // DialogSheetController via SheetController.onAnimationComplete), then
655
- // unmount. opt out with `keepChildrenMounted` if you need the old
656
- // permanent-mount behavior.
657
- const isAdaptFullyHidden = React.useContext(DialogAdaptHiddenContext)
658
747
  if (isAdapted) {
659
- if (!context.open && !context.keepChildrenMounted && isAdaptFullyHidden) {
748
+ if (
749
+ !context.open &&
750
+ !context.keepChildrenMounted &&
751
+ adaptContext.targetFullyHidden
752
+ ) {
660
753
  return null
661
754
  }
662
755
 
@@ -676,6 +769,7 @@ const DialogContentImpl = React.forwardRef<TamaguiElement, DialogContentImplProp
676
769
  data-state={getState(context.open)}
677
770
  // allow clicking through content during exit animation
678
771
  pointerEvents={context.open ? 'auto' : 'none'}
772
+ onTransition={onTransition}
679
773
  {...contentProps}
680
774
  />
681
775
  )
@@ -726,14 +820,15 @@ const DialogContentImpl = React.forwardRef<TamaguiElement, DialogContentImplProp
726
820
  * -----------------------------------------------------------------------------------------------*/
727
821
 
728
822
  const DialogTitleFrame = styled(H2, {
729
- name: 'DialogTitle',
823
+ displayName: 'DialogTitle',
730
824
  })
731
825
 
732
826
  type DialogTitleExtraProps = ScopedProps<{}>
733
827
  type DialogTitleProps = DialogTitleExtraProps & GetProps<typeof DialogTitleFrame>
734
828
 
735
- const DialogTitle = DialogTitleFrame.styleable<DialogTitleExtraProps>(
736
- function DialogTitle(props, forwardedRef) {
829
+ const DialogTitle = createStyledHOC(
830
+ DialogTitleFrame,
831
+ function DialogTitle(props: DialogTitleExtraProps, forwardedRef) {
737
832
  const { scope, ...titleProps } = props
738
833
  const context = useDialogContext(scope)
739
834
  return <DialogTitleFrame id={context.titleId} {...titleProps} ref={forwardedRef} />
@@ -745,15 +840,16 @@ const DialogTitle = DialogTitleFrame.styleable<DialogTitleExtraProps>(
745
840
  * -----------------------------------------------------------------------------------------------*/
746
841
 
747
842
  const DialogDescriptionFrame = styled(Paragraph, {
748
- name: 'DialogDescription',
843
+ displayName: 'DialogDescription',
749
844
  })
750
845
 
751
846
  type DialogDescriptionExtraProps = ScopedProps<{}>
752
847
  type DialogDescriptionProps = DialogDescriptionExtraProps &
753
848
  GetProps<typeof DialogDescriptionFrame>
754
849
 
755
- const DialogDescription = DialogDescriptionFrame.styleable<DialogDescriptionExtraProps>(
756
- function DialogDescription(props, forwardedRef) {
850
+ const DialogDescription = createStyledHOC(
851
+ DialogDescriptionFrame,
852
+ function DialogDescription(props: DialogDescriptionExtraProps, forwardedRef) {
757
853
  const { scope, ...descriptionProps } = props
758
854
  const context = useDialogContext(scope)
759
855
  return (
@@ -773,7 +869,7 @@ const DialogDescription = DialogDescriptionFrame.styleable<DialogDescriptionExtr
773
869
  const CLOSE_NAME = 'DialogClose'
774
870
 
775
871
  const DialogCloseFrame = styled(View, {
776
- name: CLOSE_NAME,
872
+ displayName: CLOSE_NAME,
777
873
  render: 'button',
778
874
  })
779
875
 
@@ -783,8 +879,9 @@ export type DialogCloseExtraProps = ScopedProps<{
783
879
 
784
880
  type DialogCloseProps = GetProps<typeof DialogCloseFrame> & DialogCloseExtraProps
785
881
 
786
- const DialogClose = DialogCloseFrame.styleable<DialogCloseExtraProps>(
787
- (props, forwardedRef) => {
882
+ const DialogClose = createStyledHOC(
883
+ DialogCloseFrame,
884
+ (props: DialogCloseProps, forwardedRef) => {
788
885
  const { scope, displayWhenAdapted, ...closeProps } = props
789
886
  const context = useDialogContext(scope)
790
887
  const isAdapted = useAdaptIsActive(context.adaptScope)
@@ -883,87 +980,83 @@ const DescriptionWarning: React.FC<DescriptionWarningProps> = ({
883
980
  * Dialog
884
981
  * -----------------------------------------------------------------------------------------------*/
885
982
 
886
- export type DialogHandle = {
887
- open: (val: boolean) => void
888
- }
889
-
890
983
  const Dialog = withStaticProperties(
891
- React.forwardRef<{ open: (val: boolean) => void }, DialogProps>(
892
- function Dialog(props, ref) {
893
- const {
894
- scope = '',
895
- children,
896
- open: openProp,
897
- defaultOpen = false,
898
- onOpenChange,
899
- modal = true,
900
- keepChildrenMounted,
901
- disableRemoveScroll = false,
902
- onAnimationComplete,
903
- } = props
904
-
905
- const baseId = React.useId()
906
- const dialogId = `Dialog-${scope}-${baseId}`
907
- const contentId = `${dialogId}-content`
908
- const titleId = `${dialogId}-title`
909
- const descriptionId = `${dialogId}-description`
910
-
911
- const triggerRef = React.useRef<TamaguiElement>(null)
912
- const contentRef = React.useRef<TamaguiElement>(null)
913
-
914
- const [open, setOpen] = useControllableState({
915
- prop: openProp,
916
- defaultProp: defaultOpen,
917
- onChange: onOpenChange,
918
- })
919
-
920
- const onOpenToggle = React.useCallback(() => {
921
- setOpen((prevOpen) => !prevOpen)
922
- }, [setOpen])
923
-
924
- const adaptScope = `DialogAdapt${scope}`
925
-
926
- const context = {
927
- dialogScope: scope,
928
- adaptScope,
929
- triggerRef,
930
- contentRef,
931
- contentId,
932
- titleId,
933
- descriptionId,
934
- open,
935
- onOpenChange: setOpen,
936
- onOpenToggle,
937
- modal,
938
- keepChildrenMounted,
939
- disableRemoveScroll,
940
- onAnimationComplete,
941
- } satisfies DialogContextValue
942
-
943
- React.useImperativeHandle(
944
- ref,
945
- () => ({
946
- open: setOpen,
947
- }),
948
- [setOpen]
949
- )
984
+ createRefComponent<TamaguiElement, DialogProps>(function Dialog(props) {
985
+ const {
986
+ scope = '',
987
+ children,
988
+ open: openProp,
989
+ defaultOpen = false,
990
+ onOpenChange,
991
+ modal = true,
992
+ keepChildrenMounted,
993
+ disableRemoveScroll = false,
994
+ onAnimationComplete,
995
+ } = props
950
996
 
951
- return (
952
- <AdaptParent
953
- scope={adaptScope}
954
- portal={{
955
- forwardProps: props,
956
- }}
957
- >
958
- <DialogProvider scope={scope} {...context}>
959
- <DialogSheetController onOpenChange={setOpen} scope={scope}>
960
- {children}
961
- </DialogSheetController>
962
- </DialogProvider>
963
- </AdaptParent>
964
- )
965
- }
966
- ),
997
+ const baseId = React.useId()
998
+ const dialogId = `Dialog-${scope}-${baseId}`
999
+ const contentId = `${dialogId}-content`
1000
+ const titleId = `${dialogId}-title`
1001
+ const descriptionId = `${dialogId}-description`
1002
+
1003
+ const triggerRef = React.useRef<TamaguiElement>(null)
1004
+ const contentRef = React.useRef<TamaguiElement>(null)
1005
+ const presentPartIdsRef = React.useRef(new Set<string>())
1006
+ const [presentPartCount, setPresentPartCount] = React.useState(0)
1007
+
1008
+ const [open, setOpen] = useControllableState({
1009
+ prop: openProp,
1010
+ defaultProp: defaultOpen,
1011
+ onChange: onOpenChange,
1012
+ })
1013
+
1014
+ const onOpenToggle = React.useCallback(() => {
1015
+ setOpen((prevOpen) => !prevOpen)
1016
+ }, [setOpen])
1017
+
1018
+ const adaptScope = `DialogAdapt${scope}`
1019
+
1020
+ const setPartPresence = React.useCallback((id: string, present: boolean) => {
1021
+ const presentPartIds = presentPartIdsRef.current
1022
+ const hasPart = presentPartIds.has(id)
1023
+
1024
+ if (present && !hasPart) {
1025
+ presentPartIds.add(id)
1026
+ setPresentPartCount(presentPartIds.size)
1027
+ } else if (!present && hasPart) {
1028
+ presentPartIds.delete(id)
1029
+ setPresentPartCount(presentPartIds.size)
1030
+ }
1031
+ }, [])
1032
+
1033
+ const context = {
1034
+ dialogScope: scope,
1035
+ adaptScope,
1036
+ triggerRef,
1037
+ contentRef,
1038
+ contentId,
1039
+ titleId,
1040
+ descriptionId,
1041
+ open,
1042
+ onOpenChange: setOpen,
1043
+ onOpenToggle,
1044
+ modal,
1045
+ keepChildrenMounted,
1046
+ disableRemoveScroll,
1047
+ hasPresentParts: presentPartCount > 0,
1048
+ setPartPresence,
1049
+ onAnimationComplete,
1050
+ } satisfies DialogContextValue
1051
+
1052
+ return (
1053
+ <AdaptParent scope={adaptScope} open={open} onOpenChange={setOpen} state={context}>
1054
+ <DialogProvider scope={scope} {...context}>
1055
+ {children}
1056
+ </DialogProvider>
1057
+ </AdaptParent>
1058
+ )
1059
+ }),
967
1060
  {
968
1061
  Trigger: DialogTrigger,
969
1062
  Portal: DialogPortal,
@@ -977,56 +1070,6 @@ const Dialog = withStaticProperties(
977
1070
  }
978
1071
  )
979
1072
 
980
- const getAdaptScope = (dialogScope: string) => `DialogAdapt${dialogScope}`
981
-
982
- const DialogSheetController = (
983
- props: ScopedProps<{
984
- children: React.ReactNode
985
- onOpenChange: React.Dispatch<React.SetStateAction<boolean>>
986
- }>
987
- ) => {
988
- const context = useDialogContext(props.scope)
989
- const isAdapted = useAdaptIsActive(context.adaptScope)
990
-
991
- // tracks whether the adapted Sheet has finished its slide-out animation.
992
- // starts true (= safe to unmount) when the dialog is closed; flips to
993
- // false the moment the dialog opens; flips back to true when the sheet
994
- // signals onAnimationComplete with open=false (i.e. slide-out finished).
995
- const [isAdaptFullyHidden, setIsAdaptFullyHidden] = React.useState(!context.open)
996
- // mirror context.open into the hidden flag during render — an opening
997
- // dialog must immediately mark its children as not-hidden so they render
998
- // for the enter animation.
999
- if (context.open && isAdaptFullyHidden) {
1000
- setIsAdaptFullyHidden(false)
1001
- }
1002
-
1003
- const handleSheetAnimationComplete = React.useCallback(
1004
- ({ open }: { open: boolean }) => {
1005
- if (!open) {
1006
- setIsAdaptFullyHidden(true)
1007
- }
1008
- },
1009
- []
1010
- )
1011
-
1012
- return (
1013
- <SheetController
1014
- onOpenChange={(val) => {
1015
- if (isAdapted) {
1016
- props.onOpenChange?.(val)
1017
- }
1018
- }}
1019
- onAnimationComplete={handleSheetAnimationComplete}
1020
- open={context.open}
1021
- hidden={!isAdapted}
1022
- >
1023
- <DialogAdaptHiddenContext.Provider value={isAdaptFullyHidden}>
1024
- {props.children}
1025
- </DialogAdaptHiddenContext.Provider>
1026
- </SheetController>
1027
- )
1028
- }
1029
-
1030
1073
  export {
1031
1074
  //
1032
1075
  Dialog,