@hanzogui/dialog 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Dialog.tsx ADDED
@@ -0,0 +1,1009 @@
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, GuiElement, 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<GuiElement | null>
79
+ contentRef: React.RefObject<GuiElement | 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
+ * DialogTrigger
103
+ * -----------------------------------------------------------------------------------------------*/
104
+
105
+ const DialogTriggerFrame = styled(View, {
106
+ name: 'DialogTrigger',
107
+ })
108
+
109
+ type DialogTriggerProps = ScopedProps<ViewProps>
110
+
111
+ const DialogTrigger = DialogTriggerFrame.styleable<ScopedProps<{}>>(
112
+ function DialogTrigger(props, forwardedRef) {
113
+ const { scope, ...triggerProps } = props
114
+ const isInsideButton = React.useContext(ButtonNestingContext)
115
+ const context = useDialogContext(scope)
116
+ const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef)
117
+ return (
118
+ <ButtonNestingContext.Provider value={true}>
119
+ <DialogTriggerFrame
120
+ render={isInsideButton ? 'span' : 'button'}
121
+ aria-haspopup="dialog"
122
+ aria-expanded={context.open}
123
+ aria-controls={context.contentId}
124
+ data-state={getState(context.open)}
125
+ {...triggerProps}
126
+ ref={composedTriggerRef}
127
+ onPress={composeEventHandlers(props.onPress as any, context.onOpenToggle)}
128
+ />
129
+ </ButtonNestingContext.Provider>
130
+ )
131
+ }
132
+ )
133
+
134
+ /* -------------------------------------------------------------------------------------------------
135
+ * DialogPortal
136
+ * -----------------------------------------------------------------------------------------------*/
137
+
138
+ type DialogPortalProps = ScopedProps<
139
+ YStackProps & {
140
+ /**
141
+ * Used to force mounting when more control is needed. Useful when
142
+ * controlling animation with React animation libraries.
143
+ */
144
+ forceMount?: boolean
145
+ }
146
+ >
147
+
148
+ export const DialogPortalFrame = styled(YStack, {
149
+ pointerEvents: 'none',
150
+ render: 'dialog',
151
+
152
+ variants: {
153
+ unstyled: {
154
+ false: {
155
+ alignItems: 'center',
156
+ justifyContent: 'center',
157
+ fullscreen: true,
158
+
159
+ '$platform-web': {
160
+ // undo dialog styles
161
+ borderWidth: 0,
162
+ backgroundColor: 'transparent',
163
+ color: 'inherit',
164
+ maxInlineSize: 'none',
165
+ margin: 0,
166
+ width: 'auto',
167
+ height: 'auto',
168
+ // ensure always in frame and right height
169
+ maxHeight: '100vh',
170
+ position: 'fixed',
171
+ // ensure dialog inherits stacking context from portal wrapper
172
+ zIndex: 1,
173
+ },
174
+ },
175
+ },
176
+ } as const,
177
+
178
+ defaultVariants: {
179
+ unstyled: process.env.HANZO_GUI_HEADLESS === '1',
180
+ },
181
+ })
182
+
183
+ const needsRepropagation = needsPortalRepropagation()
184
+
185
+ const DialogPortalItem = ({
186
+ context,
187
+ children,
188
+ }: {
189
+ context: DialogContextValue
190
+ children: React.ReactNode
191
+ }) => {
192
+ const themeName = useThemeName()
193
+ const isAdapted = useAdaptIsActive(context.adaptScope)
194
+ const adaptContext = useAdaptContext(context.adaptScope)
195
+
196
+ let content = <Theme name={themeName}>{children}</Theme>
197
+
198
+ // not just adapted - both sheet and portal for modal need it
199
+ if (needsRepropagation) {
200
+ content = (
201
+ <ProvideAdaptContext {...adaptContext}>
202
+ <DialogProvider {...context}>{content}</DialogProvider>
203
+ </ProvideAdaptContext>
204
+ )
205
+ }
206
+
207
+ // until we can use react-native portals natively
208
+ // have to re-propogate context, sketch
209
+ // when adapted we portal to the adapt, when not we portal to root modal if needed
210
+ return isAdapted ? (
211
+ <AdaptPortalContents scope={context.adaptScope}>{content}</AdaptPortalContents>
212
+ ) : context.modal ? (
213
+ <PortalItem hostName={context.modal ? 'root' : context.adaptScope}>
214
+ {content}
215
+ </PortalItem>
216
+ ) : (
217
+ content
218
+ )
219
+ }
220
+
221
+ const DialogPortal = React.forwardRef<GuiElement, DialogPortalProps>(
222
+ (props, forwardRef) => {
223
+ const { scope, forceMount, children, ...frameProps } = props
224
+ const dialogRef = React.useRef<GuiElement>(null)
225
+ const ref = composeRefs(dialogRef, forwardRef)
226
+
227
+ const context = useDialogContext(scope)
228
+ const keepMounted = forceMount || context.keepChildrenMounted
229
+ const isAdapted = useAdaptIsActive(context.adaptScope)
230
+ const [isFullyHidden, setIsFullyHidden] = React.useState(!context.open)
231
+
232
+ if (context.open && isFullyHidden) {
233
+ setIsFullyHidden(false)
234
+ }
235
+
236
+ const isVisible = context.open ? true : !isFullyHidden
237
+
238
+ if (isWeb) {
239
+ useIsomorphicLayoutEffect(() => {
240
+ const node = dialogRef.current
241
+ if (!(node instanceof HTMLDialogElement)) return
242
+ // optional chaining for JSDOM compatibility (doesn't implement show/close)
243
+ if (isVisible) {
244
+ node.show?.()
245
+ } else {
246
+ node.close?.()
247
+ }
248
+ }, [isVisible])
249
+ }
250
+
251
+ const onAnimationCompleteRef = React.useRef(context.onAnimationComplete)
252
+ onAnimationCompleteRef.current = context.onAnimationComplete
253
+
254
+ const handleExitComplete = React.useCallback(() => {
255
+ setIsFullyHidden(true)
256
+ onAnimationCompleteRef.current?.({ open: false })
257
+ }, [])
258
+
259
+ React.useEffect(() => {
260
+ if (context.open && !isAdapted && onAnimationCompleteRef.current) {
261
+ const tm = setTimeout(() => {
262
+ onAnimationCompleteRef.current?.({ open: true })
263
+ }, 350)
264
+ return () => clearTimeout(tm)
265
+ }
266
+ }, [context.open, isAdapted])
267
+
268
+ const zIndex = getExpandedShorthand('zIndex', props)
269
+
270
+ const contents = (
271
+ <StackZIndexContext zIndex={resolveViewZIndex(zIndex)}>
272
+ <Animate
273
+ type="presence"
274
+ present={Boolean(context.open)}
275
+ keepChildrenMounted={Boolean(keepMounted)}
276
+ onExitComplete={handleExitComplete}
277
+ passThrough={isAdapted}
278
+ >
279
+ {children}
280
+ </Animate>
281
+ </StackZIndexContext>
282
+ )
283
+
284
+ const framedContents =
285
+ isFullyHidden && !keepMounted && !isAdapted ? null : (
286
+ <LayoutMeasurementController disable={!context.open}>
287
+ <DialogPortalFrame
288
+ ref={ref}
289
+ {...(isWeb &&
290
+ context.open && {
291
+ 'aria-modal': true,
292
+ })}
293
+ pointerEvents={context.open ? 'auto' : 'none'}
294
+ {...frameProps}
295
+ className={`_no_backdrop ` + (frameProps.className || '')}
296
+ >
297
+ {contents}
298
+ </DialogPortalFrame>
299
+ </LayoutMeasurementController>
300
+ )
301
+
302
+ if (isWeb) {
303
+ return (
304
+ <Portal
305
+ zIndex={zIndex}
306
+ // set to 100000 which ensures dialogs are above most fixed UI (headers, navs)
307
+ // this makes sure its above typical stacking contexts
308
+ stackZIndex={100000}
309
+ passThrough={isAdapted}
310
+ >
311
+ <PassthroughTheme passThrough={isAdapted}>{framedContents}</PassthroughTheme>
312
+ </Portal>
313
+ )
314
+ }
315
+
316
+ return isAdapted ? (
317
+ framedContents
318
+ ) : (
319
+ <DialogPortalItem context={context}>{framedContents}</DialogPortalItem>
320
+ )
321
+ }
322
+ )
323
+
324
+ const PassthroughTheme = ({
325
+ children,
326
+ passThrough,
327
+ }: {
328
+ passThrough?: boolean
329
+ children?: React.ReactNode
330
+ }) => {
331
+ const themeName = useThemeName()
332
+
333
+ return (
334
+ <Theme passThrough={passThrough} name={themeName} forceClassName>
335
+ {children}
336
+ </Theme>
337
+ )
338
+ }
339
+
340
+ /* -------------------------------------------------------------------------------------------------
341
+ * DialogOverlay
342
+ * -----------------------------------------------------------------------------------------------*/
343
+
344
+ const OVERLAY_NAME = 'DialogOverlay'
345
+
346
+ /**
347
+ * exported for internal use with extractable()
348
+ */
349
+ export const DialogOverlayFrame = styled(YStack, {
350
+ name: OVERLAY_NAME,
351
+ zIndex: 1,
352
+
353
+ variants: {
354
+ open: {
355
+ true: {
356
+ pointerEvents: 'auto',
357
+ },
358
+ false: {
359
+ pointerEvents: 'none',
360
+ },
361
+ },
362
+
363
+ unstyled: {
364
+ false: {
365
+ fullscreen: true,
366
+ position: 'absolute',
367
+ backgroundColor: '$background',
368
+ pointerEvents: 'auto',
369
+ },
370
+ },
371
+ } as const,
372
+
373
+ defaultVariants: {
374
+ unstyled: process.env.HANZO_GUI_HEADLESS === '1',
375
+ },
376
+ })
377
+
378
+ export type DialogOverlayExtraProps = ScopedProps<{
379
+ /**
380
+ * Used to force mounting when more control is needed. Useful when
381
+ * controlling animation with React animation libraries.
382
+ */
383
+ forceMount?: boolean
384
+ }>
385
+
386
+ type DialogOverlayProps = YStackProps & DialogOverlayExtraProps
387
+
388
+ const DialogOverlay = DialogOverlayFrame.styleable<DialogOverlayExtraProps>(
389
+ function DialogOverlay({ scope, ...props }, forwardedRef) {
390
+ const context = useDialogContext(scope)
391
+ const { forceMount = context.forceMount, ...overlayProps } = props
392
+ const isAdapted = useAdaptIsActive(context.adaptScope)
393
+
394
+ if (!forceMount) {
395
+ if (!context.modal || isAdapted) {
396
+ return null
397
+ }
398
+ }
399
+
400
+ // Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
401
+ // ie. when `Overlay` and `Content` are siblings
402
+ return (
403
+ <DialogOverlayFrame
404
+ data-state={getState(context.open)}
405
+ // TODO: this will be apply for v2
406
+ // onPress={() => {
407
+ // // if the overlay is pressed, close the dialog
408
+ // context.onOpenChange(false)
409
+ // }}
410
+ // We re-enable pointer-events prevented by `Dialog.Content` to allow scrolling the overlay.
411
+ pointerEvents={context.open ? 'auto' : 'none'}
412
+ {...overlayProps}
413
+ ref={forwardedRef}
414
+ />
415
+ )
416
+ }
417
+ )
418
+
419
+ /* -------------------------------------------------------------------------------------------------
420
+ * DialogContent
421
+ * -----------------------------------------------------------------------------------------------*/
422
+
423
+ const CONTENT_NAME = 'DialogContent'
424
+
425
+ const DialogContentFrame = styled(ThemeableStack, {
426
+ name: CONTENT_NAME,
427
+ zIndex: 2,
428
+
429
+ variants: {
430
+ size: {
431
+ '...size': (val, extras) => {
432
+ return {}
433
+ },
434
+ },
435
+
436
+ unstyled: {
437
+ false: {
438
+ position: 'relative',
439
+ backgroundColor: '$background',
440
+ borderWidth: 1,
441
+ borderColor: '$borderColor',
442
+ padding: '$true',
443
+ borderRadius: '$true',
444
+ elevate: true,
445
+ // Ensure content receives pointer events (fixes React 19 + display:contents inheritance)
446
+ pointerEvents: 'auto',
447
+ },
448
+ },
449
+ } as const,
450
+
451
+ defaultVariants: {
452
+ size: '$true',
453
+ unstyled: process.env.HANZO_GUI_HEADLESS === '1',
454
+ },
455
+ })
456
+
457
+ type DialogContentFrameProps = GetProps<typeof DialogContentFrame>
458
+
459
+ type DialogContentExtraProps = ScopedProps<
460
+ Omit<DialogContentTypeProps, 'context' | 'onPointerDownCapture'>
461
+ >
462
+
463
+ type DialogContentProps = DialogContentFrameProps & DialogContentExtraProps
464
+
465
+ const DialogContent = DialogContentFrame.styleable<DialogContentExtraProps>(
466
+ function DialogContent({ scope, ...props }, forwardedRef) {
467
+ const context = useDialogContext(scope)
468
+
469
+ const contents = (
470
+ <>
471
+ {context.modal ? (
472
+ <DialogContentModal context={context} {...props} ref={forwardedRef} />
473
+ ) : (
474
+ <DialogContentNonModal context={context} {...props} ref={forwardedRef} />
475
+ )}
476
+ </>
477
+ )
478
+
479
+ if (!isWeb || context.disableRemoveScroll) {
480
+ return contents
481
+ }
482
+
483
+ return (
484
+ <RemoveScroll enabled={context.open}>
485
+ <div data-remove-scroll-container className="_dsp_contents">
486
+ {contents}
487
+ </div>
488
+ </RemoveScroll>
489
+ )
490
+ }
491
+ )
492
+
493
+ /* -----------------------------------------------------------------------------------------------*/
494
+
495
+ type DialogContentTypeProps = DialogContentImplProps & {
496
+ context: DialogContextValue
497
+ }
498
+
499
+ const DialogContentModal = React.forwardRef<GuiElement, DialogContentTypeProps>(
500
+ ({ children, context, ...props }, forwardedRef) => {
501
+ const contentRef = React.useRef<GuiElement>(null)
502
+ const composedRefs = useComposedRefs(forwardedRef, context.contentRef, contentRef)
503
+
504
+ return (
505
+ <DialogContentImpl
506
+ {...props}
507
+ context={context}
508
+ ref={composedRefs}
509
+ // we make sure focus isn't trapped once `DialogContent` has been closed
510
+ // (closed !== unmounted when animating out)
511
+ trapFocus={context.open}
512
+ disableOutsidePointerEvents
513
+ onCloseAutoFocus={composeEventHandlers(props.onCloseAutoFocus, (event) => {
514
+ event.preventDefault()
515
+ context.triggerRef.current?.focus()
516
+ })}
517
+ onPointerDownOutside={composeEventHandlers(
518
+ props.onPointerDownOutside,
519
+ (event) => {
520
+ const originalEvent = event['detail'].originalEvent
521
+ const ctrlLeftClick =
522
+ originalEvent.button === 0 && originalEvent.ctrlKey === true
523
+ const isRightClick = originalEvent.button === 2 || ctrlLeftClick
524
+ // If the event is a right-click, we shouldn't close because
525
+ // it is effectively as if we right-clicked the `Overlay`.
526
+ if (isRightClick) event.preventDefault()
527
+ }
528
+ )}
529
+ // When focus is trapped, a `focusout` event may still happen.
530
+ // We make sure we don't trigger our `onDismiss` in such case.
531
+ onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) =>
532
+ event.preventDefault()
533
+ )}
534
+ {...(!props.unstyled && {
535
+ outlineStyle: 'none',
536
+ })}
537
+ >
538
+ {children}
539
+ </DialogContentImpl>
540
+ )
541
+ }
542
+ )
543
+
544
+ /* -----------------------------------------------------------------------------------------------*/
545
+
546
+ const DialogContentNonModal = React.forwardRef<GuiElement, DialogContentTypeProps>(
547
+ (props, forwardedRef) => {
548
+ const hasInteractedOutsideRef = React.useRef(false)
549
+
550
+ return (
551
+ <DialogContentImpl
552
+ {...props}
553
+ ref={forwardedRef}
554
+ trapFocus={false}
555
+ disableOutsidePointerEvents={false}
556
+ onCloseAutoFocus={(event) => {
557
+ props.onCloseAutoFocus?.(event)
558
+
559
+ if (!event.defaultPrevented) {
560
+ if (!hasInteractedOutsideRef.current) {
561
+ props.context.triggerRef.current?.focus()
562
+ }
563
+ // Always prevent auto focus because we either focus manually or want user agent focus
564
+ event.preventDefault()
565
+ }
566
+
567
+ hasInteractedOutsideRef.current = false
568
+ }}
569
+ onInteractOutside={(event) => {
570
+ props.onInteractOutside?.(event)
571
+
572
+ if (!event.defaultPrevented) hasInteractedOutsideRef.current = true
573
+
574
+ // Prevent dismissing when clicking the trigger.
575
+ // As the trigger is already setup to close, without doing so would
576
+ // cause it to close and immediately open.
577
+ //
578
+ // We use `onInteractOutside` as some browsers also
579
+ // focus on pointer down, creating the same issue.
580
+ const target = event.target as HTMLElement
581
+ const trigger = props.context.triggerRef.current
582
+ if (!(trigger instanceof HTMLElement)) return
583
+ const targetIsTrigger = trigger.contains(target)
584
+ if (targetIsTrigger) event.preventDefault()
585
+ }}
586
+ />
587
+ )
588
+ }
589
+ )
590
+
591
+ /* -----------------------------------------------------------------------------------------------*/
592
+
593
+ type DialogContentImplExtraProps = Omit<DismissableProps, 'onDismiss'> & {
594
+ /**
595
+ * When `true`, focus cannot escape the `Content` via keyboard,
596
+ * pointer, or a programmatic focus.
597
+ * @defaultValue false
598
+ */
599
+ trapFocus?: FocusScopeProps['trapped']
600
+
601
+ /**
602
+ * Event handler called when auto-focusing on open.
603
+ * Can be prevented.
604
+ */
605
+ onOpenAutoFocus?: FocusScopeProps['onMountAutoFocus']
606
+
607
+ /**
608
+ * Event handler called when auto-focusing on close.
609
+ * Can be prevented.
610
+ */
611
+ onCloseAutoFocus?: FocusScopeProps['onUnmountAutoFocus']
612
+
613
+ context: DialogContextValue
614
+ }
615
+
616
+ type DialogContentImplProps = DialogContentFrameProps & DialogContentImplExtraProps
617
+
618
+ const DialogContentImpl = React.forwardRef<GuiElement, DialogContentImplProps>(
619
+ (props, forwardedRef) => {
620
+ const {
621
+ trapFocus,
622
+ onOpenAutoFocus,
623
+ onCloseAutoFocus,
624
+ disableOutsidePointerEvents,
625
+ onEscapeKeyDown,
626
+ onPointerDownOutside,
627
+ onFocusOutside,
628
+ onInteractOutside,
629
+ context,
630
+ ...contentProps
631
+ } = props
632
+
633
+ const contentRef = React.useRef<GuiElement>(null)
634
+ const composedRefs = useComposedRefs(forwardedRef, contentRef)
635
+ const isAdapted = useAdaptIsActive(context.adaptScope)
636
+
637
+ // TODO this will re-parent, ideally we would not change tree structure
638
+
639
+ if (isAdapted) {
640
+ if (!isWeb && !context.open) {
641
+ return null
642
+ }
643
+
644
+ return (
645
+ <DialogPortalItem context={context}>{contentProps.children}</DialogPortalItem>
646
+ )
647
+ }
648
+
649
+ const contents = (
650
+ <DialogContentFrame
651
+ ref={composedRefs}
652
+ id={context.contentId}
653
+ role="dialog"
654
+ aria-modal={context.modal}
655
+ aria-describedby={context.descriptionId}
656
+ aria-labelledby={context.titleId}
657
+ data-state={getState(context.open)}
658
+ // allow clicking through content during exit animation
659
+ pointerEvents={context.open ? 'auto' : 'none'}
660
+ {...contentProps}
661
+ />
662
+ )
663
+
664
+ if (!isWeb) {
665
+ return contents
666
+ }
667
+
668
+ return (
669
+ <>
670
+ <Dismissable
671
+ disableOutsidePointerEvents={context.open && disableOutsidePointerEvents}
672
+ forceUnmount={!context.open}
673
+ onEscapeKeyDown={onEscapeKeyDown}
674
+ onPointerDownOutside={onPointerDownOutside}
675
+ onFocusOutside={onFocusOutside}
676
+ onInteractOutside={onInteractOutside}
677
+ onDismiss={() => context?.onOpenChange?.(false)}
678
+ >
679
+ <FocusScope
680
+ loop
681
+ enabled={context.open}
682
+ trapped={trapFocus}
683
+ onMountAutoFocus={onOpenAutoFocus}
684
+ forceUnmount={!context.open}
685
+ onUnmountAutoFocus={onCloseAutoFocus}
686
+ >
687
+ {contents}
688
+ </FocusScope>
689
+ </Dismissable>
690
+
691
+ {process.env.NODE_ENV === 'development' && (
692
+ <>
693
+ <TitleWarning titleId={context.titleId} />
694
+ <DescriptionWarning
695
+ contentRef={contentRef as any}
696
+ descriptionId={context.descriptionId}
697
+ />
698
+ </>
699
+ )}
700
+ </>
701
+ )
702
+ }
703
+ )
704
+
705
+ /* -------------------------------------------------------------------------------------------------
706
+ * DialogTitle
707
+ * -----------------------------------------------------------------------------------------------*/
708
+
709
+ const DialogTitleFrame = styled(H2, {
710
+ name: 'DialogTitle',
711
+ })
712
+
713
+ type DialogTitleExtraProps = ScopedProps<{}>
714
+ type DialogTitleProps = DialogTitleExtraProps & GetProps<typeof DialogTitleFrame>
715
+
716
+ const DialogTitle = DialogTitleFrame.styleable<DialogTitleExtraProps>(
717
+ function DialogTitle(props, forwardedRef) {
718
+ const { scope, ...titleProps } = props
719
+ const context = useDialogContext(scope)
720
+ return <DialogTitleFrame id={context.titleId} {...titleProps} ref={forwardedRef} />
721
+ }
722
+ )
723
+
724
+ /* -------------------------------------------------------------------------------------------------
725
+ * DialogDescription
726
+ * -----------------------------------------------------------------------------------------------*/
727
+
728
+ const DialogDescriptionFrame = styled(Paragraph, {
729
+ name: 'DialogDescription',
730
+ })
731
+
732
+ type DialogDescriptionExtraProps = ScopedProps<{}>
733
+ type DialogDescriptionProps = DialogDescriptionExtraProps &
734
+ GetProps<typeof DialogDescriptionFrame>
735
+
736
+ const DialogDescription = DialogDescriptionFrame.styleable<DialogDescriptionExtraProps>(
737
+ function DialogDescription(props, forwardedRef) {
738
+ const { scope, ...descriptionProps } = props
739
+ const context = useDialogContext(scope)
740
+ return (
741
+ <DialogDescriptionFrame
742
+ id={context.descriptionId}
743
+ {...descriptionProps}
744
+ ref={forwardedRef}
745
+ />
746
+ )
747
+ }
748
+ )
749
+
750
+ /* -------------------------------------------------------------------------------------------------
751
+ * DialogClose
752
+ * -----------------------------------------------------------------------------------------------*/
753
+
754
+ const CLOSE_NAME = 'DialogClose'
755
+
756
+ const DialogCloseFrame = styled(View, {
757
+ name: CLOSE_NAME,
758
+ render: 'button',
759
+ })
760
+
761
+ export type DialogCloseExtraProps = ScopedProps<{
762
+ displayWhenAdapted?: boolean
763
+ }>
764
+
765
+ type DialogCloseProps = GetProps<typeof DialogCloseFrame> & DialogCloseExtraProps
766
+
767
+ const DialogClose = DialogCloseFrame.styleable<DialogCloseExtraProps>(
768
+ (props, forwardedRef) => {
769
+ const { scope, displayWhenAdapted, ...closeProps } = props
770
+ const context = useDialogContext(scope)
771
+ const isAdapted = useAdaptIsActive(context.adaptScope)
772
+ const isInsideButton = React.useContext(ButtonNestingContext)
773
+
774
+ if (isAdapted && !displayWhenAdapted) {
775
+ return null
776
+ }
777
+
778
+ return (
779
+ <DialogCloseFrame
780
+ aria-label="Dialog Close"
781
+ render={isInsideButton ? 'span' : 'button'}
782
+ {...closeProps}
783
+ ref={forwardedRef}
784
+ onPress={composeEventHandlers(props.onPress as any, () => {
785
+ context.onOpenChange(false)
786
+ })}
787
+ />
788
+ )
789
+ }
790
+ )
791
+
792
+ /* -----------------------------------------------------------------------------------------------*/
793
+
794
+ function getState(open: boolean) {
795
+ return open ? 'open' : 'closed'
796
+ }
797
+
798
+ const TITLE_WARNING_NAME = 'DialogTitleWarning'
799
+
800
+ const [DialogWarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, {
801
+ contentName: CONTENT_NAME,
802
+ titleName: 'DialogTitle',
803
+ docsSlug: 'dialog',
804
+ })
805
+
806
+ type TitleWarningProps = { titleId?: string }
807
+
808
+ const TitleWarning: React.FC<TitleWarningProps> = ({ titleId }) => {
809
+ if (process.env.NODE_ENV === 'development') {
810
+ const titleWarningContext = useWarningContext(TITLE_WARNING_NAME)
811
+
812
+ const MESSAGE = `\`${titleWarningContext.contentName}\` wants a \`${titleWarningContext.titleName}\` to be accessible. If you want to hide the \`${titleWarningContext.titleName}\`, wrap it with <VisuallyHidden />.`
813
+
814
+ React.useEffect(() => {
815
+ if (!isWeb) return
816
+ if (titleId) {
817
+ const hasTitle = document.getElementById(titleId)
818
+ if (!hasTitle) {
819
+ console.warn(MESSAGE)
820
+ }
821
+ }
822
+ }, [MESSAGE, titleId])
823
+ }
824
+
825
+ return null
826
+ }
827
+
828
+ const DESCRIPTION_WARNING_NAME = 'DialogDescriptionWarning'
829
+
830
+ type DescriptionWarningProps = {
831
+ contentRef: React.RefObject<GuiElement>
832
+ descriptionId?: string
833
+ }
834
+
835
+ const DescriptionWarning: React.FC<DescriptionWarningProps> = ({
836
+ contentRef,
837
+ descriptionId,
838
+ }) => {
839
+ if (process.env.NODE_ENV === 'development') {
840
+ const descriptionWarningContext = useWarningContext(DESCRIPTION_WARNING_NAME)
841
+ const MESSAGE = `Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${descriptionWarningContext.contentName}}.`
842
+
843
+ React.useEffect(() => {
844
+ if (!isWeb) return
845
+ const contentNode = contentRef.current
846
+ if (!(contentNode instanceof HTMLElement)) {
847
+ return
848
+ }
849
+ const describedById = contentNode.getAttribute('aria-describedby')
850
+ // if we have an id and the user hasn't set aria-describedby={undefined}
851
+ if (descriptionId && describedById) {
852
+ const hasDescription = document.getElementById(descriptionId)
853
+ if (!hasDescription) {
854
+ console.warn(MESSAGE)
855
+ }
856
+ }
857
+ }, [MESSAGE, contentRef, descriptionId])
858
+ }
859
+
860
+ return null
861
+ }
862
+
863
+ /* -------------------------------------------------------------------------------------------------
864
+ * Dialog
865
+ * -----------------------------------------------------------------------------------------------*/
866
+
867
+ export type DialogHandle = {
868
+ open: (val: boolean) => void
869
+ }
870
+
871
+ const Dialog = withStaticProperties(
872
+ React.forwardRef<{ open: (val: boolean) => void }, DialogProps>(
873
+ function Dialog(props, ref) {
874
+ const {
875
+ scope = '',
876
+ children,
877
+ open: openProp,
878
+ defaultOpen = false,
879
+ onOpenChange,
880
+ modal = true,
881
+ keepChildrenMounted,
882
+ disableRemoveScroll = false,
883
+ onAnimationComplete,
884
+ } = props
885
+
886
+ const baseId = React.useId()
887
+ const dialogId = `Dialog-${scope}-${baseId}`
888
+ const contentId = `${dialogId}-content`
889
+ const titleId = `${dialogId}-title`
890
+ const descriptionId = `${dialogId}-description`
891
+
892
+ const triggerRef = React.useRef<GuiElement>(null)
893
+ const contentRef = React.useRef<GuiElement>(null)
894
+
895
+ const [open, setOpen] = useControllableState({
896
+ prop: openProp,
897
+ defaultProp: defaultOpen,
898
+ onChange: onOpenChange,
899
+ })
900
+
901
+ const onOpenToggle = React.useCallback(() => {
902
+ setOpen((prevOpen) => !prevOpen)
903
+ }, [setOpen])
904
+
905
+ const adaptScope = `DialogAdapt${scope}`
906
+
907
+ const context = {
908
+ dialogScope: scope,
909
+ adaptScope,
910
+ triggerRef,
911
+ contentRef,
912
+ contentId,
913
+ titleId,
914
+ descriptionId,
915
+ open,
916
+ onOpenChange: setOpen,
917
+ onOpenToggle,
918
+ modal,
919
+ keepChildrenMounted,
920
+ disableRemoveScroll,
921
+ onAnimationComplete,
922
+ } satisfies DialogContextValue
923
+
924
+ React.useImperativeHandle(
925
+ ref,
926
+ () => ({
927
+ open: setOpen,
928
+ }),
929
+ [setOpen]
930
+ )
931
+
932
+ return (
933
+ <AdaptParent
934
+ scope={adaptScope}
935
+ portal={{
936
+ forwardProps: props,
937
+ }}
938
+ >
939
+ <DialogProvider scope={scope} {...context}>
940
+ <DialogSheetController onOpenChange={setOpen} scope={scope}>
941
+ {children}
942
+ </DialogSheetController>
943
+ </DialogProvider>
944
+ </AdaptParent>
945
+ )
946
+ }
947
+ ),
948
+ {
949
+ Trigger: DialogTrigger,
950
+ Portal: DialogPortal,
951
+ Overlay: DialogOverlay,
952
+ Content: DialogContent,
953
+ Title: DialogTitle,
954
+ Description: DialogDescription,
955
+ Close: DialogClose,
956
+ FocusScope: FocusScopeController,
957
+ Adapt,
958
+ }
959
+ )
960
+
961
+ const getAdaptScope = (dialogScope: string) => `DialogAdapt${dialogScope}`
962
+
963
+ const DialogSheetController = (
964
+ props: ScopedProps<{
965
+ children: React.ReactNode
966
+ onOpenChange: React.Dispatch<React.SetStateAction<boolean>>
967
+ }>
968
+ ) => {
969
+ const context = useDialogContext(props.scope)
970
+ const isAdapted = useAdaptIsActive(context.adaptScope)
971
+
972
+ return (
973
+ <SheetController
974
+ onOpenChange={(val) => {
975
+ if (isAdapted) {
976
+ props.onOpenChange?.(val)
977
+ }
978
+ }}
979
+ open={context.open}
980
+ hidden={!isAdapted}
981
+ >
982
+ {props.children}
983
+ </SheetController>
984
+ )
985
+ }
986
+
987
+ export {
988
+ //
989
+ Dialog,
990
+ DialogClose,
991
+ DialogContent,
992
+ DialogDescription,
993
+ DialogOverlay,
994
+ DialogPortal,
995
+ DialogTitle,
996
+ DialogTrigger,
997
+ //
998
+ DialogWarningProvider,
999
+ }
1000
+ export type {
1001
+ DialogCloseProps,
1002
+ DialogContentProps,
1003
+ DialogDescriptionProps,
1004
+ DialogOverlayProps,
1005
+ DialogPortalProps,
1006
+ DialogProps,
1007
+ DialogTitleProps,
1008
+ DialogTriggerProps,
1009
+ }