@tamagui/dialog 1.0.1-beta.59 → 1.0.1-beta.60

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamagui/dialog",
3
- "version": "1.0.1-beta.59",
3
+ "version": "1.0.1-beta.60",
4
4
  "sideEffects": true,
5
5
  "source": "src/index.ts",
6
6
  "types": "./types/index.d.ts",
@@ -8,6 +8,7 @@
8
8
  "module": "dist/esm",
9
9
  "module:jsx": "dist/jsx",
10
10
  "files": [
11
+ "src",
11
12
  "types",
12
13
  "dist"
13
14
  ],
@@ -18,18 +19,18 @@
18
19
  "clean:build": "tamagui-build clean:build"
19
20
  },
20
21
  "dependencies": {
21
- "@tamagui/animate-presence": "^1.0.1-beta.59",
22
- "@tamagui/compose-refs": "^1.0.1-beta.59",
23
- "@tamagui/core": "^1.0.1-beta.59",
24
- "@tamagui/create-context": "^1.0.1-beta.59",
25
- "@tamagui/dismissable": "^1.0.1-beta.59",
26
- "@tamagui/focus-scope": "^1.0.1-beta.59",
27
- "@tamagui/polyfill-dev": "^1.0.1-beta.59",
28
- "@tamagui/popper": "^1.0.1-beta.59",
29
- "@tamagui/portal": "^1.0.1-beta.59",
30
- "@tamagui/stacks": "^1.0.1-beta.59",
31
- "@tamagui/text": "^1.0.1-beta.59",
32
- "@tamagui/use-controllable-state": "^1.0.1-beta.59",
22
+ "@tamagui/animate-presence": "^1.0.1-beta.60",
23
+ "@tamagui/compose-refs": "^1.0.1-beta.60",
24
+ "@tamagui/core": "^1.0.1-beta.60",
25
+ "@tamagui/create-context": "^1.0.1-beta.60",
26
+ "@tamagui/dismissable": "^1.0.1-beta.60",
27
+ "@tamagui/focus-scope": "^1.0.1-beta.60",
28
+ "@tamagui/polyfill-dev": "^1.0.1-beta.60",
29
+ "@tamagui/popper": "^1.0.1-beta.60",
30
+ "@tamagui/portal": "^1.0.1-beta.60",
31
+ "@tamagui/stacks": "^1.0.1-beta.60",
32
+ "@tamagui/text": "^1.0.1-beta.60",
33
+ "@tamagui/use-controllable-state": "^1.0.1-beta.60",
33
34
  "react-remove-scroll": "^2.5.3"
34
35
  },
35
36
  "peerDependencies": {
@@ -38,7 +39,7 @@
38
39
  "react-native": "*"
39
40
  },
40
41
  "devDependencies": {
41
- "@tamagui/build": "^1.0.1-beta.59",
42
+ "@tamagui/build": "^1.0.1-beta.60",
42
43
  "@types/react-dom": "^18.0.3",
43
44
  "@types/react-native": "^0.67.3",
44
45
  "react": "*",
package/src/Dialog.tsx ADDED
@@ -0,0 +1,666 @@
1
+ import { AnimatePresence } from '@tamagui/animate-presence'
2
+ import { useComposedRefs } from '@tamagui/compose-refs'
3
+ import {
4
+ GetProps,
5
+ Slot,
6
+ Theme,
7
+ composeEventHandlers,
8
+ isWeb,
9
+ styled,
10
+ useId,
11
+ useThemeName,
12
+ withStaticProperties,
13
+ } from '@tamagui/core'
14
+ import { Scope, createContext, createContextScope } from '@tamagui/create-context'
15
+ import { Dismissable, DismissableProps } from '@tamagui/dismissable'
16
+ import { FocusScope, FocusScopeProps } from '@tamagui/focus-scope'
17
+ import { Portal, PortalProps } from '@tamagui/portal'
18
+ import { ThemeableStack, YStack, YStackProps } from '@tamagui/stacks'
19
+ import { H2, Paragraph } from '@tamagui/text'
20
+ import { useControllableState } from '@tamagui/use-controllable-state'
21
+ import * as React from 'react'
22
+ import { View } from 'react-native'
23
+ import { RemoveScroll } from 'react-remove-scroll'
24
+ // import { hideOthers } from 'aria-hidden';
25
+
26
+ const DIALOG_NAME = 'Dialog'
27
+
28
+ type ScopedProps<P> = P & { __scopeDialog?: Scope }
29
+ type TamaguiElement = HTMLElement | View
30
+
31
+ const [createDialogContext, createDialogScope] = createContextScope(DIALOG_NAME)
32
+
33
+ type DialogContextValue = {
34
+ triggerRef: React.RefObject<TamaguiElement>
35
+ contentRef: React.RefObject<TamaguiElement>
36
+ contentId: string
37
+ titleId: string
38
+ descriptionId: string
39
+ open: boolean
40
+ onOpenChange(open: boolean): void
41
+ onOpenToggle(): void
42
+ modal: boolean
43
+ allowPinchZoom: boolean
44
+ // allowPinchZoom: DialogProps['allowPinchZoom'];
45
+ }
46
+
47
+ const [DialogProvider, useDialogContext] = createDialogContext<DialogContextValue>(DIALOG_NAME)
48
+
49
+ type RemoveScrollProps = React.ComponentProps<typeof RemoveScroll>
50
+
51
+ interface DialogProps {
52
+ children?: React.ReactNode
53
+ open?: boolean
54
+ defaultOpen?: boolean
55
+ onOpenChange?(open: boolean): void
56
+ modal?: boolean
57
+
58
+ /**
59
+ * @see https://github.com/theKashey/react-remove-scroll#usage
60
+ */
61
+ allowPinchZoom?: RemoveScrollProps['allowPinchZoom']
62
+ }
63
+
64
+ /* -------------------------------------------------------------------------------------------------
65
+ * DialogTrigger
66
+ * -----------------------------------------------------------------------------------------------*/
67
+
68
+ const TRIGGER_NAME = 'DialogTrigger'
69
+
70
+ const DialogTriggerFrame = styled(YStack, {
71
+ name: TRIGGER_NAME,
72
+ })
73
+
74
+ interface DialogTriggerProps extends YStackProps {}
75
+
76
+ const DialogTrigger = React.forwardRef<TamaguiElement, DialogTriggerProps>(
77
+ (props: ScopedProps<DialogTriggerProps>, forwardedRef) => {
78
+ const { __scopeDialog, ...triggerProps } = props
79
+ const context = useDialogContext(TRIGGER_NAME, __scopeDialog)
80
+ const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef)
81
+ return (
82
+ <DialogTriggerFrame
83
+ tag="button"
84
+ aria-haspopup="dialog"
85
+ aria-expanded={context.open}
86
+ aria-controls={context.contentId}
87
+ data-state={getState(context.open)}
88
+ {...triggerProps}
89
+ ref={composedTriggerRef}
90
+ onPress={composeEventHandlers(props.onPress, context.onOpenToggle)}
91
+ />
92
+ )
93
+ }
94
+ )
95
+
96
+ DialogTrigger.displayName = TRIGGER_NAME
97
+
98
+ /* -------------------------------------------------------------------------------------------------
99
+ * DialogPortal
100
+ * -----------------------------------------------------------------------------------------------*/
101
+
102
+ const PORTAL_NAME = 'DialogPortal'
103
+
104
+ type PortalContextValue = { forceMount?: true }
105
+ const [PortalProvider, usePortalContext] = createDialogContext<PortalContextValue>(PORTAL_NAME, {
106
+ forceMount: undefined,
107
+ })
108
+
109
+ interface DialogPortalProps extends Omit<PortalProps, 'asChild'> {
110
+ children?: React.ReactNode
111
+ /**
112
+ * Used to force mounting when more control is needed. Useful when
113
+ * controlling animation with React animation libraries.
114
+ */
115
+ forceMount?: true
116
+ }
117
+
118
+ const DialogPortal: React.FC<DialogPortalProps> = (props: ScopedProps<DialogPortalProps>) => {
119
+ const { __scopeDialog, forceMount, children, ...rest } = props
120
+ const themeName = useThemeName()
121
+ const context = useDialogContext(PORTAL_NAME, __scopeDialog)
122
+ const isShowing = forceMount || context.open
123
+ const contents = <AnimatePresence>{isShowing ? children : null}</AnimatePresence>
124
+ if (!context.modal) {
125
+ return contents
126
+ }
127
+ if (!isWeb && !isShowing) {
128
+ return contents
129
+ }
130
+ return (
131
+ <PortalProvider scope={__scopeDialog} forceMount={forceMount}>
132
+ <Portal
133
+ alignItems="center"
134
+ justifyContent="center"
135
+ zIndex={100}
136
+ pointerEvents={isShowing ? 'auto' : 'none'}
137
+ {...(isWeb && {
138
+ maxHeight: '100vh',
139
+ })}
140
+ {...rest}
141
+ >
142
+ <Theme name={themeName}>{contents}</Theme>
143
+ </Portal>
144
+ </PortalProvider>
145
+ )
146
+ }
147
+
148
+ DialogPortal.displayName = PORTAL_NAME
149
+
150
+ /* -------------------------------------------------------------------------------------------------
151
+ * DialogOverlay
152
+ * -----------------------------------------------------------------------------------------------*/
153
+
154
+ const OVERLAY_NAME = 'DialogOverlay'
155
+
156
+ const DialogOverlayFrame = styled(ThemeableStack, {
157
+ name: OVERLAY_NAME,
158
+ pointerEvents: 'auto',
159
+ backgrounded: true,
160
+ fullscreen: true,
161
+ })
162
+
163
+ interface DialogOverlayProps extends YStackProps {
164
+ /**
165
+ * Used to force mounting when more control is needed. Useful when
166
+ * controlling animation with React animation libraries.
167
+ */
168
+ forceMount?: true
169
+ }
170
+
171
+ const DialogOverlay = React.forwardRef<TamaguiElement, DialogOverlayProps>(
172
+ (props: ScopedProps<DialogOverlayProps>, forwardedRef) => {
173
+ const portalContext = usePortalContext(OVERLAY_NAME, props.__scopeDialog)
174
+ const { forceMount = portalContext.forceMount, ...overlayProps } = props
175
+ const context = useDialogContext(OVERLAY_NAME, props.__scopeDialog)
176
+
177
+ if (!context.modal) {
178
+ return null
179
+ }
180
+
181
+ // <AnimatePresence>
182
+ return <DialogOverlayImpl {...overlayProps} ref={forwardedRef} />
183
+ // </AnimatePresence>
184
+ }
185
+ )
186
+
187
+ DialogOverlay.displayName = OVERLAY_NAME
188
+
189
+ type DialogOverlayImplProps = GetProps<typeof DialogOverlayFrame>
190
+
191
+ const DialogOverlayImpl = React.forwardRef<TamaguiElement, DialogOverlayImplProps>(
192
+ (props: ScopedProps<DialogOverlayImplProps>, forwardedRef) => {
193
+ const { __scopeDialog, ...overlayProps } = props
194
+ const context = useDialogContext(OVERLAY_NAME, __scopeDialog)
195
+ const content = (
196
+ <DialogOverlayFrame
197
+ data-state={getState(context.open)}
198
+ // We re-enable pointer-events prevented by `Dialog.Content` to allow scrolling the overlay.
199
+ pointerEvents="auto"
200
+ {...overlayProps}
201
+ ref={forwardedRef}
202
+ />
203
+ )
204
+
205
+ if (!isWeb) {
206
+ return content
207
+ }
208
+
209
+ return (
210
+ // Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
211
+ // ie. when `Overlay` and `Content` are siblings
212
+ <RemoveScroll as={Slot} allowPinchZoom={context.allowPinchZoom} shards={[context.contentRef]}>
213
+ {content}
214
+ </RemoveScroll>
215
+ )
216
+ }
217
+ )
218
+
219
+ /* -------------------------------------------------------------------------------------------------
220
+ * DialogContent
221
+ * -----------------------------------------------------------------------------------------------*/
222
+
223
+ const CONTENT_NAME = 'DialogContent'
224
+
225
+ const DialogContentFrame = styled(ThemeableStack, {
226
+ name: CONTENT_NAME,
227
+ tag: 'dialog',
228
+ pointerEvents: 'auto',
229
+ position: 'relative',
230
+ backgrounded: true,
231
+ padded: true,
232
+ radiused: true,
233
+ elevate: true,
234
+
235
+ variants: {
236
+ size: {
237
+ '...size': (val, extras) => {
238
+ return {}
239
+ },
240
+ },
241
+ },
242
+
243
+ defaultVariants: {
244
+ size: '$4',
245
+ },
246
+ })
247
+
248
+ type DialogContentFrameProps = GetProps<typeof DialogContentFrame>
249
+
250
+ type DialogContentProps = DialogContentFrameProps & {
251
+ /**
252
+ * Used to force mounting when more control is needed. Useful when
253
+ * controlling animation with React animation libraries.
254
+ */
255
+ forceMount?: true
256
+ }
257
+
258
+ const DialogContent = React.forwardRef<TamaguiElement, DialogContentProps>(
259
+ (props: ScopedProps<DialogContentProps>, forwardedRef) => {
260
+ const portalContext = usePortalContext(CONTENT_NAME, props.__scopeDialog)
261
+ const { forceMount = portalContext.forceMount, ...contentProps } = props
262
+ const context = useDialogContext(CONTENT_NAME, props.__scopeDialog)
263
+ return (
264
+ <>
265
+ {context.modal ? (
266
+ <DialogContentModal {...contentProps} ref={forwardedRef} />
267
+ ) : (
268
+ <DialogContentNonModal {...contentProps} ref={forwardedRef} />
269
+ )}
270
+ </>
271
+ )
272
+ }
273
+ )
274
+
275
+ DialogContent.displayName = CONTENT_NAME
276
+
277
+ /* -----------------------------------------------------------------------------------------------*/
278
+
279
+ interface DialogContentTypeProps
280
+ extends Omit<DialogContentImplProps, 'trapFocus' | 'disableOutsidePointerEvents'> {}
281
+
282
+ const DialogContentModal = React.forwardRef<TamaguiElement, DialogContentTypeProps>(
283
+ (props: ScopedProps<DialogContentTypeProps>, forwardedRef) => {
284
+ const context = useDialogContext(CONTENT_NAME, props.__scopeDialog)
285
+ const contentRef = React.useRef<HTMLDivElement>(null)
286
+ const composedRefs = useComposedRefs(forwardedRef, context.contentRef, contentRef)
287
+
288
+ // aria-hide everything except the content (better supported equivalent to setting aria-modal)
289
+ // React.useEffect(() => {
290
+ // const content = contentRef.current
291
+ // if (content) {
292
+ // console.log('should hide others')
293
+ // // return hideOthers(content)
294
+ // }
295
+ // }, [])
296
+
297
+ return (
298
+ <DialogContentImpl
299
+ {...props}
300
+ ref={composedRefs}
301
+ // we make sure focus isn't trapped once `DialogContent` has been closed
302
+ // (closed !== unmounted when animating out)
303
+ trapFocus={context.open}
304
+ disableOutsidePointerEvents
305
+ onCloseAutoFocus={composeEventHandlers(props.onCloseAutoFocus, (event) => {
306
+ event.preventDefault()
307
+ context.triggerRef.current?.focus()
308
+ })}
309
+ onPointerDownOutside={composeEventHandlers(props.onPointerDownOutside, (event) => {
310
+ const originalEvent = event['detail'].originalEvent
311
+ const ctrlLeftClick = originalEvent.button === 0 && originalEvent.ctrlKey === true
312
+ const isRightClick = originalEvent.button === 2 || ctrlLeftClick
313
+ // If the event is a right-click, we shouldn't close because
314
+ // it is effectively as if we right-clicked the `Overlay`.
315
+ if (isRightClick) event.preventDefault()
316
+ })}
317
+ // When focus is trapped, a `focusout` event may still happen.
318
+ // We make sure we don't trigger our `onDismiss` in such case.
319
+ onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) =>
320
+ event.preventDefault()
321
+ )}
322
+ />
323
+ )
324
+ }
325
+ )
326
+
327
+ /* -----------------------------------------------------------------------------------------------*/
328
+
329
+ const DialogContentNonModal = React.forwardRef<TamaguiElement, DialogContentTypeProps>(
330
+ (props: ScopedProps<DialogContentTypeProps>, forwardedRef) => {
331
+ const context = useDialogContext(CONTENT_NAME, props.__scopeDialog)
332
+ const hasInteractedOutsideRef = React.useRef(false)
333
+
334
+ return (
335
+ <DialogContentImpl
336
+ {...props}
337
+ ref={forwardedRef}
338
+ trapFocus={false}
339
+ disableOutsidePointerEvents={false}
340
+ onCloseAutoFocus={(event) => {
341
+ props.onCloseAutoFocus?.(event)
342
+
343
+ if (!event.defaultPrevented) {
344
+ if (!hasInteractedOutsideRef.current) context.triggerRef.current?.focus()
345
+ // Always prevent auto focus because we either focus manually or want user agent focus
346
+ event.preventDefault()
347
+ }
348
+
349
+ hasInteractedOutsideRef.current = false
350
+ }}
351
+ onInteractOutside={(event) => {
352
+ props.onInteractOutside?.(event)
353
+
354
+ if (!event.defaultPrevented) hasInteractedOutsideRef.current = true
355
+
356
+ // Prevent dismissing when clicking the trigger.
357
+ // As the trigger is already setup to close, without doing so would
358
+ // cause it to close and immediately open.
359
+ //
360
+ // We use `onInteractOutside` as some browsers also
361
+ // focus on pointer down, creating the same issue.
362
+ const target = event.target as HTMLElement
363
+ const trigger = context.triggerRef.current
364
+ if (!(trigger instanceof HTMLElement)) return
365
+ const targetIsTrigger = trigger.contains(target)
366
+ if (targetIsTrigger) event.preventDefault()
367
+ }}
368
+ />
369
+ )
370
+ }
371
+ )
372
+
373
+ /* -----------------------------------------------------------------------------------------------*/
374
+
375
+ type DialogContentImplProps = DialogContentFrameProps &
376
+ Omit<DismissableProps, 'onDismiss'> & {
377
+ /**
378
+ * When `true`, focus cannot escape the `Content` via keyboard,
379
+ * pointer, or a programmatic focus.
380
+ * @defaultValue false
381
+ */
382
+ trapFocus?: FocusScopeProps['trapped']
383
+
384
+ /**
385
+ * Event handler called when auto-focusing on open.
386
+ * Can be prevented.
387
+ */
388
+ onOpenAutoFocus?: FocusScopeProps['onMountAutoFocus']
389
+
390
+ /**
391
+ * Event handler called when auto-focusing on close.
392
+ * Can be prevented.
393
+ */
394
+ onCloseAutoFocus?: FocusScopeProps['onUnmountAutoFocus']
395
+ }
396
+
397
+ const DialogContentImpl = React.forwardRef<TamaguiElement, DialogContentImplProps>(
398
+ (props: ScopedProps<DialogContentImplProps>, forwardedRef) => {
399
+ const {
400
+ __scopeDialog,
401
+ trapFocus,
402
+ onOpenAutoFocus,
403
+ onCloseAutoFocus,
404
+ disableOutsidePointerEvents,
405
+ onEscapeKeyDown,
406
+ onPointerDownOutside,
407
+ onFocusOutside,
408
+ onInteractOutside,
409
+ ...contentProps
410
+ } = props
411
+ const context = useDialogContext(CONTENT_NAME, __scopeDialog)
412
+ const contentRef = React.useRef<HTMLDivElement>(null)
413
+ const composedRefs = useComposedRefs(forwardedRef, contentRef)
414
+
415
+ // Make sure the whole tree has focus guards as our `Dialog` will be
416
+ // the last element in the DOM (beacuse of the `Portal`)
417
+ // useFocusGuards();
418
+
419
+ return (
420
+ <>
421
+ <FocusScope
422
+ loop
423
+ trapped={trapFocus}
424
+ onMountAutoFocus={onOpenAutoFocus}
425
+ onUnmountAutoFocus={onCloseAutoFocus}
426
+ >
427
+ <Dismissable
428
+ disableOutsidePointerEvents={disableOutsidePointerEvents}
429
+ onEscapeKeyDown={onEscapeKeyDown}
430
+ onPointerDownOutside={onPointerDownOutside}
431
+ onFocusOutside={onFocusOutside}
432
+ onInteractOutside={onInteractOutside}
433
+ // @ts-ignore
434
+ ref={composedRefs}
435
+ onDismiss={() => context.onOpenChange(false)}
436
+ >
437
+ <DialogContentFrame
438
+ id={context.contentId}
439
+ aria-describedby={context.descriptionId}
440
+ aria-labelledby={context.titleId}
441
+ data-state={getState(context.open)}
442
+ {...contentProps}
443
+ />
444
+ </Dismissable>
445
+ </FocusScope>
446
+ {process.env.NODE_ENV !== 'production' && (
447
+ <>
448
+ <TitleWarning titleId={context.titleId} />
449
+ <DescriptionWarning contentRef={contentRef} descriptionId={context.descriptionId} />
450
+ </>
451
+ )}
452
+ </>
453
+ )
454
+ }
455
+ )
456
+
457
+ /* -------------------------------------------------------------------------------------------------
458
+ * DialogTitle
459
+ * -----------------------------------------------------------------------------------------------*/
460
+
461
+ const TITLE_NAME = 'DialogTitle'
462
+ const DialogTitleFrame = styled(H2, {
463
+ name: TITLE_NAME,
464
+ })
465
+
466
+ type DialogTitleProps = GetProps<typeof DialogTitleFrame>
467
+
468
+ const DialogTitle = React.forwardRef<TamaguiElement, DialogTitleProps>(
469
+ (props: ScopedProps<DialogTitleProps>, forwardedRef) => {
470
+ const { __scopeDialog, ...titleProps } = props
471
+ const context = useDialogContext(TITLE_NAME, __scopeDialog)
472
+ return <DialogTitleFrame id={context.titleId} {...titleProps} ref={forwardedRef} />
473
+ }
474
+ )
475
+
476
+ DialogTitle.displayName = TITLE_NAME
477
+
478
+ /* -------------------------------------------------------------------------------------------------
479
+ * DialogDescription
480
+ * -----------------------------------------------------------------------------------------------*/
481
+
482
+ const DialogDescriptionFrame = styled(Paragraph, {
483
+ name: 'DialogDescription',
484
+ })
485
+
486
+ type DialogDescriptionProps = GetProps<typeof DialogDescriptionFrame>
487
+
488
+ const DESCRIPTION_NAME = 'DialogDescription'
489
+
490
+ const DialogDescription = React.forwardRef<TamaguiElement, DialogDescriptionProps>(
491
+ (props: ScopedProps<DialogDescriptionProps>, forwardedRef) => {
492
+ const { __scopeDialog, ...descriptionProps } = props
493
+ const context = useDialogContext(DESCRIPTION_NAME, __scopeDialog)
494
+ return (
495
+ <DialogDescriptionFrame id={context.descriptionId} {...descriptionProps} ref={forwardedRef} />
496
+ )
497
+ }
498
+ )
499
+
500
+ DialogDescription.displayName = DESCRIPTION_NAME
501
+
502
+ /* -------------------------------------------------------------------------------------------------
503
+ * DialogClose
504
+ * -----------------------------------------------------------------------------------------------*/
505
+
506
+ const CLOSE_NAME = 'DialogClose'
507
+
508
+ type DialogCloseProps = YStackProps
509
+
510
+ const DialogClose = React.forwardRef<TamaguiElement, DialogCloseProps>(
511
+ (props: ScopedProps<DialogCloseProps>, forwardedRef) => {
512
+ const { __scopeDialog, ...closeProps } = props
513
+ const context = useDialogContext(CLOSE_NAME, __scopeDialog)
514
+ return (
515
+ <YStack
516
+ tag="button"
517
+ {...closeProps}
518
+ ref={forwardedRef}
519
+ onPress={composeEventHandlers(props.onPress, () => context.onOpenChange(false))}
520
+ />
521
+ )
522
+ }
523
+ )
524
+
525
+ DialogClose.displayName = CLOSE_NAME
526
+
527
+ /* -----------------------------------------------------------------------------------------------*/
528
+
529
+ function getState(open: boolean) {
530
+ return open ? 'open' : 'closed'
531
+ }
532
+
533
+ const TITLE_WARNING_NAME = 'DialogTitleWarning'
534
+
535
+ const [WarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, {
536
+ contentName: CONTENT_NAME,
537
+ titleName: TITLE_NAME,
538
+ docsSlug: 'dialog',
539
+ })
540
+
541
+ type TitleWarningProps = { titleId?: string }
542
+
543
+ const TitleWarning: React.FC<TitleWarningProps> = ({ titleId }) => {
544
+ const titleWarningContext = useWarningContext(TITLE_WARNING_NAME)
545
+
546
+ const MESSAGE = `\`${titleWarningContext.contentName}\` requires a \`${titleWarningContext.titleName}\` for the component to be accessible for screen reader users.
547
+
548
+ If you want to hide the \`${titleWarningContext.titleName}\`, you can wrap it with our VisuallyHidden component.
549
+
550
+ For more information, see https://radix-ui.com/primitives/docs/components/${titleWarningContext.docsSlug}`
551
+
552
+ React.useEffect(() => {
553
+ if (!isWeb) return
554
+ if (titleId) {
555
+ const hasTitle = document.getElementById(titleId)
556
+ if (!hasTitle) throw new Error(MESSAGE)
557
+ }
558
+ }, [MESSAGE, titleId])
559
+
560
+ return null
561
+ }
562
+
563
+ const DESCRIPTION_WARNING_NAME = 'DialogDescriptionWarning'
564
+
565
+ type DescriptionWarningProps = {
566
+ contentRef: React.RefObject<TamaguiElement>
567
+ descriptionId?: string
568
+ }
569
+
570
+ const DescriptionWarning: React.FC<DescriptionWarningProps> = ({ contentRef, descriptionId }) => {
571
+ const descriptionWarningContext = useWarningContext(DESCRIPTION_WARNING_NAME)
572
+ const MESSAGE = `Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${descriptionWarningContext.contentName}}.`
573
+
574
+ React.useEffect(() => {
575
+ if (!isWeb) return
576
+ const contentNode = contentRef.current
577
+ if (!(contentNode instanceof HTMLElement)) {
578
+ return
579
+ }
580
+ const describedById = contentNode.getAttribute('aria-describedby')
581
+ // if we have an id and the user hasn't set aria-describedby={undefined}
582
+ if (descriptionId && describedById) {
583
+ const hasDescription = document.getElementById(descriptionId)
584
+ if (!hasDescription) console.warn(MESSAGE)
585
+ }
586
+ }, [MESSAGE, contentRef, descriptionId])
587
+
588
+ return null
589
+ }
590
+
591
+ /* -------------------------------------------------------------------------------------------------
592
+ * Dialog
593
+ * -----------------------------------------------------------------------------------------------*/
594
+
595
+ const Dialog = withStaticProperties(
596
+ function Dialog(props: ScopedProps<DialogProps>) {
597
+ const {
598
+ __scopeDialog,
599
+ children,
600
+ open: openProp,
601
+ defaultOpen = false,
602
+ onOpenChange,
603
+ modal = true,
604
+ allowPinchZoom,
605
+ } = props
606
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
607
+ const contentRef = React.useRef<TamaguiElement>(null)
608
+ const [open = false, setOpen] = useControllableState({
609
+ prop: openProp,
610
+ defaultProp: defaultOpen,
611
+ onChange: onOpenChange,
612
+ })
613
+
614
+ return (
615
+ <DialogProvider
616
+ scope={__scopeDialog}
617
+ triggerRef={triggerRef}
618
+ contentRef={contentRef}
619
+ contentId={useId() || ''}
620
+ titleId={useId() || ''}
621
+ descriptionId={useId() || ''}
622
+ open={open}
623
+ onOpenChange={setOpen}
624
+ onOpenToggle={React.useCallback(() => setOpen((prevOpen) => !prevOpen), [setOpen])}
625
+ modal={modal}
626
+ allowPinchZoom={allowPinchZoom || false}
627
+ >
628
+ {children}
629
+ </DialogProvider>
630
+ )
631
+ },
632
+ {
633
+ Trigger: DialogTrigger,
634
+ Portal: DialogPortal,
635
+ Overlay: DialogOverlay,
636
+ Content: DialogContent,
637
+ Title: DialogTitle,
638
+ Description: DialogDescription,
639
+ Close: DialogClose,
640
+ }
641
+ )
642
+
643
+ export {
644
+ createDialogScope,
645
+ //
646
+ Dialog,
647
+ DialogTrigger,
648
+ DialogPortal,
649
+ DialogOverlay,
650
+ DialogContent,
651
+ DialogTitle,
652
+ DialogDescription,
653
+ DialogClose,
654
+ //
655
+ WarningProvider,
656
+ }
657
+ export type {
658
+ DialogProps,
659
+ DialogTriggerProps,
660
+ DialogPortalProps,
661
+ DialogOverlayProps,
662
+ DialogContentProps,
663
+ DialogTitleProps,
664
+ DialogDescriptionProps,
665
+ DialogCloseProps,
666
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './Dialog'