@hanzogui/dialog 2.0.0-rc.41-hanzoai.3

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 ADDED
@@ -0,0 +1,1052 @@
1
+ import {
2
+ Adapt,
3
+ AdaptParent,
4
+ AdaptPortalContents,
5
+ ProvideAdaptContext,
6
+ useAdaptContext,
7
+ useAdaptIsActive,
8
+ } from '@hanzogui/adapt'
9
+ import { Animate } from '@hanzogui/animate'
10
+ import { composeRefs, useComposedRefs } from '@hanzogui/compose-refs'
11
+ import { isWeb, useIsomorphicLayoutEffect } from '@hanzogui/constants'
12
+ import type { GetProps, HanzoguiElement, ViewProps } from '@hanzogui/core'
13
+ import {
14
+ createStyledContext,
15
+ getExpandedShorthand,
16
+ LayoutMeasurementController,
17
+ styled,
18
+ Theme,
19
+ useThemeName,
20
+ View,
21
+ } from '@hanzogui/core'
22
+ import { createContext } from '@hanzogui/create-context'
23
+ import type { DismissableProps } from '@hanzogui/dismissable'
24
+ import { Dismissable } from '@hanzogui/dismissable'
25
+ import type { FocusScopeProps } from '@hanzogui/focus-scope'
26
+ import { FocusScope, FocusScopeController } from '@hanzogui/focus-scope'
27
+ import { composeEventHandlers, withStaticProperties } from '@hanzogui/helpers'
28
+ import {
29
+ needsPortalRepropagation,
30
+ Portal,
31
+ PortalItem,
32
+ resolveViewZIndex,
33
+ } from '@hanzogui/portal'
34
+ import { RemoveScroll } from '@hanzogui/remove-scroll'
35
+ import { SheetController } from '@hanzogui/sheet/controller'
36
+ import type { YStackProps } from '@hanzogui/stacks'
37
+ import { ButtonNestingContext, ThemeableStack, YStack } from '@hanzogui/stacks'
38
+ import { H2, Paragraph } from '@hanzogui/text'
39
+ import { useControllableState } from '@hanzogui/use-controllable-state'
40
+ import { StackZIndexContext } from '@hanzogui/z-index-stack'
41
+ import * as React from 'react'
42
+
43
+ export type DialogScopes = string
44
+
45
+ type ScopedProps<P> = P & { scope?: DialogScopes }
46
+
47
+ type DialogProps = ScopedProps<{
48
+ children?: React.ReactNode
49
+ open?: boolean
50
+ defaultOpen?: boolean
51
+
52
+ /**
53
+ * When true, children never un-mount, otherwise they mount on open.
54
+ *
55
+ * @default false
56
+ */
57
+ keepChildrenMounted?: boolean
58
+ onOpenChange?(open: boolean): void
59
+ modal?: boolean
60
+
61
+ /**
62
+ * Used to disable the remove scroll functionality when open
63
+ */
64
+ disableRemoveScroll?: boolean
65
+
66
+ /**
67
+ * Called when the dialog open/close animation completes.
68
+ */
69
+ onAnimationComplete?: (info: { open: boolean }) => void
70
+ }>
71
+
72
+ type NonNull<A> = Exclude<A, void | null>
73
+
74
+ type DialogContextValue = {
75
+ forceMount?: boolean
76
+ keepChildrenMounted?: boolean
77
+ disableRemoveScroll?: boolean
78
+ triggerRef: React.RefObject<HanzoguiElement | null>
79
+ contentRef: React.RefObject<HanzoguiElement | null>
80
+ contentId: string
81
+ titleId: string
82
+ descriptionId: string
83
+ onOpenToggle(): void
84
+ open: NonNull<DialogProps['open']>
85
+ onOpenChange: NonNull<DialogProps['onOpenChange']>
86
+ modal: NonNull<DialogProps['modal']>
87
+ dialogScope: DialogScopes
88
+ adaptScope: string
89
+ onAnimationComplete?: DialogProps['onAnimationComplete']
90
+ }
91
+
92
+ export const DialogContext = createStyledContext<DialogContextValue>(
93
+ // since we always provide this we can avoid setting here
94
+ {} as DialogContextValue,
95
+ 'Dialog__'
96
+ )
97
+
98
+ export const { useStyledContext: useDialogContext, Provider: DialogProvider } =
99
+ DialogContext
100
+
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
+ /* -------------------------------------------------------------------------------------------------
114
+ * DialogTrigger
115
+ * -----------------------------------------------------------------------------------------------*/
116
+
117
+ const DialogTriggerFrame = styled(View, {
118
+ name: 'DialogTrigger',
119
+ })
120
+
121
+ type DialogTriggerProps = ScopedProps<ViewProps>
122
+
123
+ const DialogTrigger = DialogTriggerFrame.styleable<ScopedProps<{}>>(
124
+ function DialogTrigger(props, forwardedRef) {
125
+ const { scope, ...triggerProps } = props
126
+ const isInsideButton = React.useContext(ButtonNestingContext)
127
+ const context = useDialogContext(scope)
128
+ const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef)
129
+ return (
130
+ <ButtonNestingContext.Provider value={true}>
131
+ <DialogTriggerFrame
132
+ render={isInsideButton ? 'span' : 'button'}
133
+ aria-haspopup="dialog"
134
+ aria-expanded={context.open}
135
+ aria-controls={context.contentId}
136
+ data-state={getState(context.open)}
137
+ {...triggerProps}
138
+ ref={composedTriggerRef}
139
+ onPress={composeEventHandlers(props.onPress as any, context.onOpenToggle)}
140
+ />
141
+ </ButtonNestingContext.Provider>
142
+ )
143
+ }
144
+ )
145
+
146
+ /* -------------------------------------------------------------------------------------------------
147
+ * DialogPortal
148
+ * -----------------------------------------------------------------------------------------------*/
149
+
150
+ type DialogPortalProps = ScopedProps<
151
+ YStackProps & {
152
+ /**
153
+ * Used to force mounting when more control is needed. Useful when
154
+ * controlling animation with React animation libraries.
155
+ */
156
+ forceMount?: boolean
157
+ }
158
+ >
159
+
160
+ export const DialogPortalFrame = styled(YStack, {
161
+ pointerEvents: 'none',
162
+ 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.HANZOGUI_HEADLESS === '1',
192
+ },
193
+ })
194
+
195
+ const needsRepropagation = needsPortalRepropagation()
196
+
197
+ const DialogPortalItem = ({
198
+ context,
199
+ children,
200
+ }: {
201
+ context: DialogContextValue
202
+ children: React.ReactNode
203
+ }) => {
204
+ const themeName = useThemeName()
205
+ const isAdapted = useAdaptIsActive(context.adaptScope)
206
+ const adaptContext = useAdaptContext(context.adaptScope)
207
+
208
+ let content = <Theme name={themeName}>{children}</Theme>
209
+
210
+ // not just adapted - both sheet and portal for modal need it
211
+ if (needsRepropagation) {
212
+ content = (
213
+ <ProvideAdaptContext {...adaptContext}>
214
+ <DialogProvider {...context}>{content}</DialogProvider>
215
+ </ProvideAdaptContext>
216
+ )
217
+ }
218
+
219
+ // until we can use react-native portals natively
220
+ // have to re-propogate context, sketch
221
+ // when adapted we portal to the adapt, when not we portal to root modal if needed
222
+ return isAdapted ? (
223
+ <AdaptPortalContents scope={context.adaptScope}>{content}</AdaptPortalContents>
224
+ ) : context.modal ? (
225
+ <PortalItem hostName={context.modal ? 'root' : context.adaptScope}>
226
+ {content}
227
+ </PortalItem>
228
+ ) : (
229
+ content
230
+ )
231
+ }
232
+
233
+ const DialogPortal = React.forwardRef<HanzoguiElement, DialogPortalProps>(
234
+ (props, forwardRef) => {
235
+ const { scope, forceMount, children, ...frameProps } = props
236
+ const dialogRef = React.useRef<HanzoguiElement>(null)
237
+ const ref = composeRefs(dialogRef, forwardRef)
238
+
239
+ const context = useDialogContext(scope)
240
+ const keepMounted = forceMount || context.keepChildrenMounted
241
+ 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
249
+
250
+ if (isWeb) {
251
+ useIsomorphicLayoutEffect(() => {
252
+ const node = dialogRef.current
253
+ if (!(node instanceof HTMLDialogElement)) return
254
+ // optional chaining for JSDOM compatibility (doesn't implement show/close)
255
+ if (isVisible) {
256
+ node.show?.()
257
+ } else {
258
+ node.close?.()
259
+ }
260
+ }, [isVisible])
261
+ }
262
+
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
+ const zIndex = getExpandedShorthand('zIndex', props)
281
+
282
+ const contents = (
283
+ <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
+ >
291
+ {children}
292
+ </Animate>
293
+ </StackZIndexContext>
294
+ )
295
+
296
+ const framedContents =
297
+ isFullyHidden && !keepMounted && !isAdapted ? null : (
298
+ <LayoutMeasurementController disable={!context.open}>
299
+ <DialogPortalFrame
300
+ ref={ref}
301
+ {...(isWeb &&
302
+ context.open && {
303
+ 'aria-modal': true,
304
+ })}
305
+ pointerEvents={context.open ? 'auto' : 'none'}
306
+ {...frameProps}
307
+ className={`_no_backdrop ` + (frameProps.className || '')}
308
+ >
309
+ {contents}
310
+ </DialogPortalFrame>
311
+ </LayoutMeasurementController>
312
+ )
313
+
314
+ if (isWeb) {
315
+ return (
316
+ <Portal
317
+ zIndex={zIndex}
318
+ // set to 100000 which ensures dialogs are above most fixed UI (headers, navs)
319
+ // this makes sure its above typical stacking contexts
320
+ stackZIndex={100000}
321
+ passThrough={isAdapted}
322
+ >
323
+ <PassthroughTheme passThrough={isAdapted}>{framedContents}</PassthroughTheme>
324
+ </Portal>
325
+ )
326
+ }
327
+
328
+ return isAdapted ? (
329
+ framedContents
330
+ ) : (
331
+ <DialogPortalItem context={context}>{framedContents}</DialogPortalItem>
332
+ )
333
+ }
334
+ )
335
+
336
+ const PassthroughTheme = ({
337
+ children,
338
+ passThrough,
339
+ }: {
340
+ passThrough?: boolean
341
+ children?: React.ReactNode
342
+ }) => {
343
+ const themeName = useThemeName()
344
+
345
+ return (
346
+ <Theme passThrough={passThrough} name={themeName} forceClassName>
347
+ {children}
348
+ </Theme>
349
+ )
350
+ }
351
+
352
+ /* -------------------------------------------------------------------------------------------------
353
+ * DialogOverlay
354
+ * -----------------------------------------------------------------------------------------------*/
355
+
356
+ const OVERLAY_NAME = 'DialogOverlay'
357
+
358
+ /**
359
+ * exported for internal use with extractable()
360
+ */
361
+ export const DialogOverlayFrame = styled(YStack, {
362
+ name: OVERLAY_NAME,
363
+ zIndex: 1,
364
+
365
+ variants: {
366
+ open: {
367
+ true: {
368
+ pointerEvents: 'auto',
369
+ },
370
+ false: {
371
+ pointerEvents: 'none',
372
+ },
373
+ },
374
+
375
+ unstyled: {
376
+ false: {
377
+ fullscreen: true,
378
+ position: 'absolute',
379
+ backgroundColor: '$background',
380
+ pointerEvents: 'auto',
381
+ },
382
+ },
383
+ } as const,
384
+
385
+ defaultVariants: {
386
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
387
+ },
388
+ })
389
+
390
+ export type DialogOverlayExtraProps = ScopedProps<{
391
+ /**
392
+ * Used to force mounting when more control is needed. Useful when
393
+ * controlling animation with React animation libraries.
394
+ */
395
+ forceMount?: boolean
396
+ }>
397
+
398
+ type DialogOverlayProps = YStackProps & DialogOverlayExtraProps
399
+
400
+ const DialogOverlay = DialogOverlayFrame.styleable<DialogOverlayExtraProps>(
401
+ function DialogOverlay({ scope, ...props }, forwardedRef) {
402
+ const context = useDialogContext(scope)
403
+ const { forceMount = context.forceMount, ...overlayProps } = props
404
+ const isAdapted = useAdaptIsActive(context.adaptScope)
405
+
406
+ if (!forceMount) {
407
+ if (!context.modal || isAdapted) {
408
+ return null
409
+ }
410
+ }
411
+
412
+ // Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
413
+ // ie. when `Overlay` and `Content` are siblings
414
+ 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
+ />
427
+ )
428
+ }
429
+ )
430
+
431
+ /* -------------------------------------------------------------------------------------------------
432
+ * DialogContent
433
+ * -----------------------------------------------------------------------------------------------*/
434
+
435
+ const CONTENT_NAME = 'DialogContent'
436
+
437
+ const DialogContentFrame = styled(ThemeableStack, {
438
+ name: CONTENT_NAME,
439
+ zIndex: 2,
440
+
441
+ variants: {
442
+ size: {
443
+ '...size': (val, extras) => {
444
+ return {}
445
+ },
446
+ },
447
+
448
+ unstyled: {
449
+ false: {
450
+ position: 'relative',
451
+ backgroundColor: '$background',
452
+ 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',
459
+ },
460
+ },
461
+ } as const,
462
+
463
+ defaultVariants: {
464
+ size: '$true',
465
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
466
+ },
467
+ })
468
+
469
+ type DialogContentFrameProps = GetProps<typeof DialogContentFrame>
470
+
471
+ type DialogContentExtraProps = ScopedProps<
472
+ Omit<DialogContentTypeProps, 'context' | 'onPointerDownCapture'>
473
+ >
474
+
475
+ type DialogContentProps = DialogContentFrameProps & DialogContentExtraProps
476
+
477
+ const DialogContent = DialogContentFrame.styleable<DialogContentExtraProps>(
478
+ function DialogContent({ scope, ...props }, forwardedRef) {
479
+ const context = useDialogContext(scope)
480
+
481
+ const contents = (
482
+ <>
483
+ {context.modal ? (
484
+ <DialogContentModal context={context} {...props} ref={forwardedRef} />
485
+ ) : (
486
+ <DialogContentNonModal context={context} {...props} ref={forwardedRef} />
487
+ )}
488
+ </>
489
+ )
490
+
491
+ if (!isWeb || context.disableRemoveScroll) {
492
+ return contents
493
+ }
494
+
495
+ return (
496
+ <RemoveScroll enabled={context.open}>
497
+ <div data-remove-scroll-container className="_dsp_contents">
498
+ {contents}
499
+ </div>
500
+ </RemoveScroll>
501
+ )
502
+ }
503
+ )
504
+
505
+ /* -----------------------------------------------------------------------------------------------*/
506
+
507
+ type DialogContentTypeProps = DialogContentImplProps & {
508
+ context: DialogContextValue
509
+ }
510
+
511
+ const DialogContentModal = React.forwardRef<HanzoguiElement, DialogContentTypeProps>(
512
+ ({ children, context, ...props }, forwardedRef) => {
513
+ const contentRef = React.useRef<HanzoguiElement>(null)
514
+ const composedRefs = useComposedRefs(forwardedRef, context.contentRef, contentRef)
515
+
516
+ return (
517
+ <DialogContentImpl
518
+ {...props}
519
+ context={context}
520
+ ref={composedRefs}
521
+ // we make sure focus isn't trapped once `DialogContent` has been closed
522
+ // (closed !== unmounted when animating out)
523
+ trapFocus={context.open}
524
+ disableOutsidePointerEvents
525
+ onCloseAutoFocus={composeEventHandlers(props.onCloseAutoFocus, (event) => {
526
+ event.preventDefault()
527
+ context.triggerRef.current?.focus()
528
+ })}
529
+ onPointerDownOutside={composeEventHandlers(
530
+ props.onPointerDownOutside,
531
+ (event) => {
532
+ const originalEvent = event['detail'].originalEvent
533
+ const ctrlLeftClick =
534
+ originalEvent.button === 0 && originalEvent.ctrlKey === true
535
+ const isRightClick = originalEvent.button === 2 || ctrlLeftClick
536
+ // If the event is a right-click, we shouldn't close because
537
+ // it is effectively as if we right-clicked the `Overlay`.
538
+ if (isRightClick) event.preventDefault()
539
+ }
540
+ )}
541
+ // When focus is trapped, a `focusout` event may still happen.
542
+ // We make sure we don't trigger our `onDismiss` in such case.
543
+ onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) =>
544
+ event.preventDefault()
545
+ )}
546
+ {...(!props.unstyled && {
547
+ outlineStyle: 'none',
548
+ })}
549
+ >
550
+ {children}
551
+ </DialogContentImpl>
552
+ )
553
+ }
554
+ )
555
+
556
+ /* -----------------------------------------------------------------------------------------------*/
557
+
558
+ const DialogContentNonModal = React.forwardRef<HanzoguiElement, DialogContentTypeProps>(
559
+ (props, forwardedRef) => {
560
+ const hasInteractedOutsideRef = React.useRef(false)
561
+
562
+ return (
563
+ <DialogContentImpl
564
+ {...props}
565
+ ref={forwardedRef}
566
+ trapFocus={false}
567
+ disableOutsidePointerEvents={false}
568
+ onCloseAutoFocus={(event) => {
569
+ props.onCloseAutoFocus?.(event)
570
+
571
+ if (!event.defaultPrevented) {
572
+ if (!hasInteractedOutsideRef.current) {
573
+ props.context.triggerRef.current?.focus()
574
+ }
575
+ // Always prevent auto focus because we either focus manually or want user agent focus
576
+ event.preventDefault()
577
+ }
578
+
579
+ hasInteractedOutsideRef.current = false
580
+ }}
581
+ onInteractOutside={(event) => {
582
+ props.onInteractOutside?.(event)
583
+
584
+ if (!event.defaultPrevented) hasInteractedOutsideRef.current = true
585
+
586
+ // Prevent dismissing when clicking the trigger.
587
+ // As the trigger is already setup to close, without doing so would
588
+ // cause it to close and immediately open.
589
+ //
590
+ // We use `onInteractOutside` as some browsers also
591
+ // focus on pointer down, creating the same issue.
592
+ const target = event.target as HTMLElement
593
+ const trigger = props.context.triggerRef.current
594
+ if (!(trigger instanceof HTMLElement)) return
595
+ const targetIsTrigger = trigger.contains(target)
596
+ if (targetIsTrigger) event.preventDefault()
597
+ }}
598
+ />
599
+ )
600
+ }
601
+ )
602
+
603
+ /* -----------------------------------------------------------------------------------------------*/
604
+
605
+ type DialogContentImplExtraProps = Omit<DismissableProps, 'onDismiss'> & {
606
+ /**
607
+ * When `true`, focus cannot escape the `Content` via keyboard,
608
+ * pointer, or a programmatic focus.
609
+ * @defaultValue false
610
+ */
611
+ trapFocus?: FocusScopeProps['trapped']
612
+
613
+ /**
614
+ * Event handler called when auto-focusing on open.
615
+ * Can be prevented.
616
+ */
617
+ onOpenAutoFocus?: FocusScopeProps['onMountAutoFocus']
618
+
619
+ /**
620
+ * Event handler called when auto-focusing on close.
621
+ * Can be prevented.
622
+ */
623
+ onCloseAutoFocus?: FocusScopeProps['onUnmountAutoFocus']
624
+
625
+ context: DialogContextValue
626
+ }
627
+
628
+ type DialogContentImplProps = DialogContentFrameProps & DialogContentImplExtraProps
629
+
630
+ const DialogContentImpl = React.forwardRef<HanzoguiElement, DialogContentImplProps>(
631
+ (props, forwardedRef) => {
632
+ const {
633
+ trapFocus,
634
+ onOpenAutoFocus,
635
+ onCloseAutoFocus,
636
+ disableOutsidePointerEvents,
637
+ onEscapeKeyDown,
638
+ onPointerDownOutside,
639
+ onFocusOutside,
640
+ onInteractOutside,
641
+ context,
642
+ ...contentProps
643
+ } = props
644
+
645
+ const contentRef = React.useRef<HanzoguiElement>(null)
646
+ const composedRefs = useComposedRefs(forwardedRef, contentRef)
647
+ const isAdapted = useAdaptIsActive(context.adaptScope)
648
+
649
+ // TODO this will re-parent, ideally we would not change tree structure
650
+
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
+ if (isAdapted) {
659
+ if (!context.open && !context.keepChildrenMounted && isAdaptFullyHidden) {
660
+ return null
661
+ }
662
+
663
+ return (
664
+ <DialogPortalItem context={context}>{contentProps.children}</DialogPortalItem>
665
+ )
666
+ }
667
+
668
+ const contents = (
669
+ <DialogContentFrame
670
+ ref={composedRefs}
671
+ id={context.contentId}
672
+ role="dialog"
673
+ aria-modal={context.modal}
674
+ aria-describedby={context.descriptionId}
675
+ aria-labelledby={context.titleId}
676
+ data-state={getState(context.open)}
677
+ // allow clicking through content during exit animation
678
+ pointerEvents={context.open ? 'auto' : 'none'}
679
+ {...contentProps}
680
+ />
681
+ )
682
+
683
+ if (!isWeb) {
684
+ return contents
685
+ }
686
+
687
+ return (
688
+ <>
689
+ <Dismissable
690
+ disableOutsidePointerEvents={context.open && disableOutsidePointerEvents}
691
+ forceUnmount={!context.open}
692
+ onEscapeKeyDown={onEscapeKeyDown}
693
+ onPointerDownOutside={onPointerDownOutside}
694
+ onFocusOutside={onFocusOutside}
695
+ onInteractOutside={onInteractOutside}
696
+ onDismiss={() => context?.onOpenChange?.(false)}
697
+ >
698
+ <FocusScope
699
+ loop
700
+ enabled={context.open}
701
+ trapped={trapFocus}
702
+ onMountAutoFocus={onOpenAutoFocus}
703
+ forceUnmount={!context.open}
704
+ onUnmountAutoFocus={onCloseAutoFocus}
705
+ >
706
+ {contents}
707
+ </FocusScope>
708
+ </Dismissable>
709
+
710
+ {process.env.NODE_ENV === 'development' && (
711
+ <>
712
+ <TitleWarning titleId={context.titleId} />
713
+ <DescriptionWarning
714
+ contentRef={contentRef as any}
715
+ descriptionId={context.descriptionId}
716
+ />
717
+ </>
718
+ )}
719
+ </>
720
+ )
721
+ }
722
+ )
723
+
724
+ /* -------------------------------------------------------------------------------------------------
725
+ * DialogTitle
726
+ * -----------------------------------------------------------------------------------------------*/
727
+
728
+ const DialogTitleFrame = styled(H2, {
729
+ name: 'DialogTitle',
730
+ })
731
+
732
+ type DialogTitleExtraProps = ScopedProps<{}>
733
+ type DialogTitleProps = DialogTitleExtraProps & GetProps<typeof DialogTitleFrame>
734
+
735
+ const DialogTitle = DialogTitleFrame.styleable<DialogTitleExtraProps>(
736
+ function DialogTitle(props, forwardedRef) {
737
+ const { scope, ...titleProps } = props
738
+ const context = useDialogContext(scope)
739
+ return <DialogTitleFrame id={context.titleId} {...titleProps} ref={forwardedRef} />
740
+ }
741
+ )
742
+
743
+ /* -------------------------------------------------------------------------------------------------
744
+ * DialogDescription
745
+ * -----------------------------------------------------------------------------------------------*/
746
+
747
+ const DialogDescriptionFrame = styled(Paragraph, {
748
+ name: 'DialogDescription',
749
+ })
750
+
751
+ type DialogDescriptionExtraProps = ScopedProps<{}>
752
+ type DialogDescriptionProps = DialogDescriptionExtraProps &
753
+ GetProps<typeof DialogDescriptionFrame>
754
+
755
+ const DialogDescription = DialogDescriptionFrame.styleable<DialogDescriptionExtraProps>(
756
+ function DialogDescription(props, forwardedRef) {
757
+ const { scope, ...descriptionProps } = props
758
+ const context = useDialogContext(scope)
759
+ return (
760
+ <DialogDescriptionFrame
761
+ id={context.descriptionId}
762
+ {...descriptionProps}
763
+ ref={forwardedRef}
764
+ />
765
+ )
766
+ }
767
+ )
768
+
769
+ /* -------------------------------------------------------------------------------------------------
770
+ * DialogClose
771
+ * -----------------------------------------------------------------------------------------------*/
772
+
773
+ const CLOSE_NAME = 'DialogClose'
774
+
775
+ const DialogCloseFrame = styled(View, {
776
+ name: CLOSE_NAME,
777
+ render: 'button',
778
+ })
779
+
780
+ export type DialogCloseExtraProps = ScopedProps<{
781
+ displayWhenAdapted?: boolean
782
+ }>
783
+
784
+ type DialogCloseProps = GetProps<typeof DialogCloseFrame> & DialogCloseExtraProps
785
+
786
+ const DialogClose = DialogCloseFrame.styleable<DialogCloseExtraProps>(
787
+ (props, forwardedRef) => {
788
+ const { scope, displayWhenAdapted, ...closeProps } = props
789
+ const context = useDialogContext(scope)
790
+ const isAdapted = useAdaptIsActive(context.adaptScope)
791
+ const isInsideButton = React.useContext(ButtonNestingContext)
792
+
793
+ if (isAdapted && !displayWhenAdapted) {
794
+ return null
795
+ }
796
+
797
+ return (
798
+ <DialogCloseFrame
799
+ aria-label="Dialog Close"
800
+ render={isInsideButton ? 'span' : 'button'}
801
+ {...closeProps}
802
+ ref={forwardedRef}
803
+ onPress={composeEventHandlers(props.onPress as any, () => {
804
+ context.onOpenChange(false)
805
+ })}
806
+ />
807
+ )
808
+ }
809
+ )
810
+
811
+ /* -----------------------------------------------------------------------------------------------*/
812
+
813
+ function getState(open: boolean) {
814
+ return open ? 'open' : 'closed'
815
+ }
816
+
817
+ const TITLE_WARNING_NAME = 'DialogTitleWarning'
818
+
819
+ const [DialogWarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, {
820
+ contentName: CONTENT_NAME,
821
+ titleName: 'DialogTitle',
822
+ docsSlug: 'dialog',
823
+ })
824
+
825
+ type TitleWarningProps = { titleId?: string }
826
+
827
+ const TitleWarning: React.FC<TitleWarningProps> = ({ titleId }) => {
828
+ if (process.env.NODE_ENV === 'development') {
829
+ const titleWarningContext = useWarningContext(TITLE_WARNING_NAME)
830
+
831
+ const MESSAGE = `\`${titleWarningContext.contentName}\` wants a \`${titleWarningContext.titleName}\` to be accessible. If you want to hide the \`${titleWarningContext.titleName}\`, wrap it with <VisuallyHidden />.`
832
+
833
+ React.useEffect(() => {
834
+ if (!isWeb) return
835
+ if (titleId) {
836
+ const hasTitle = document.getElementById(titleId)
837
+ if (!hasTitle) {
838
+ console.warn(MESSAGE)
839
+ }
840
+ }
841
+ }, [MESSAGE, titleId])
842
+ }
843
+
844
+ return null
845
+ }
846
+
847
+ const DESCRIPTION_WARNING_NAME = 'DialogDescriptionWarning'
848
+
849
+ type DescriptionWarningProps = {
850
+ contentRef: React.RefObject<HanzoguiElement>
851
+ descriptionId?: string
852
+ }
853
+
854
+ const DescriptionWarning: React.FC<DescriptionWarningProps> = ({
855
+ contentRef,
856
+ descriptionId,
857
+ }) => {
858
+ if (process.env.NODE_ENV === 'development') {
859
+ const descriptionWarningContext = useWarningContext(DESCRIPTION_WARNING_NAME)
860
+ const MESSAGE = `Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${descriptionWarningContext.contentName}}.`
861
+
862
+ React.useEffect(() => {
863
+ if (!isWeb) return
864
+ const contentNode = contentRef.current
865
+ if (!(contentNode instanceof HTMLElement)) {
866
+ return
867
+ }
868
+ const describedById = contentNode.getAttribute('aria-describedby')
869
+ // if we have an id and the user hasn't set aria-describedby={undefined}
870
+ if (descriptionId && describedById) {
871
+ const hasDescription = document.getElementById(descriptionId)
872
+ if (!hasDescription) {
873
+ console.warn(MESSAGE)
874
+ }
875
+ }
876
+ }, [MESSAGE, contentRef, descriptionId])
877
+ }
878
+
879
+ return null
880
+ }
881
+
882
+ /* -------------------------------------------------------------------------------------------------
883
+ * Dialog
884
+ * -----------------------------------------------------------------------------------------------*/
885
+
886
+ export type DialogHandle = {
887
+ open: (val: boolean) => void
888
+ }
889
+
890
+ 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<HanzoguiElement>(null)
912
+ const contentRef = React.useRef<HanzoguiElement>(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
+ )
950
+
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
+ ),
967
+ {
968
+ Trigger: DialogTrigger,
969
+ Portal: DialogPortal,
970
+ Overlay: DialogOverlay,
971
+ Content: DialogContent,
972
+ Title: DialogTitle,
973
+ Description: DialogDescription,
974
+ Close: DialogClose,
975
+ FocusScope: FocusScopeController,
976
+ Adapt,
977
+ }
978
+ )
979
+
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
+ export {
1031
+ //
1032
+ Dialog,
1033
+ DialogClose,
1034
+ DialogContent,
1035
+ DialogDescription,
1036
+ DialogOverlay,
1037
+ DialogPortal,
1038
+ DialogTitle,
1039
+ DialogTrigger,
1040
+ //
1041
+ DialogWarningProvider,
1042
+ }
1043
+ export type {
1044
+ DialogCloseProps,
1045
+ DialogContentProps,
1046
+ DialogDescriptionProps,
1047
+ DialogOverlayProps,
1048
+ DialogPortalProps,
1049
+ DialogProps,
1050
+ DialogTitleProps,
1051
+ DialogTriggerProps,
1052
+ }