@tamagui/toast 1.0.1-beta.139

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/Toast.tsx ADDED
@@ -0,0 +1,964 @@
1
+ // adapted from https://raw.githubusercontent.com/radix-ui/primitives/main/packages/react/toast/src/Toast.tsx
2
+ // https://swiftobc.com/repo/BastiaanJansen-Toast-Swift
3
+
4
+ import { AnimatePresence } from '@tamagui/animate-presence'
5
+ import { useComposedRefs } from '@tamagui/compose-refs'
6
+ import {
7
+ Slot,
8
+ TamaguiElement,
9
+ composeEventHandlers,
10
+ isWeb,
11
+ useEvent,
12
+ useIsomorphicLayoutEffect,
13
+ } from '@tamagui/core'
14
+ import { createContextScope } from '@tamagui/create-context'
15
+ import type { Scope } from '@tamagui/create-context'
16
+ import * as DismissableLayer from '@tamagui/dismissable'
17
+ import { Portal } from '@tamagui/portal'
18
+ import { YStack } from '@tamagui/stacks'
19
+ import { useControllableState } from '@tamagui/use-controllable-state'
20
+ import { VisuallyHidden } from '@tamagui/visually-hidden'
21
+ import * as React from 'react'
22
+ import * as ReactDOM from 'react-dom'
23
+
24
+ /* -------------------------------------------------------------------------------------------------
25
+ * ToastProvider
26
+ * -----------------------------------------------------------------------------------------------*/
27
+
28
+ const PROVIDER_NAME = 'ToastProvider'
29
+
30
+ type SwipeDirection = 'up' | 'down' | 'left' | 'right'
31
+ type ToastProviderContextValue = {
32
+ label: string
33
+ duration: number
34
+ swipeDirection: SwipeDirection
35
+ swipeThreshold: number
36
+ toastCount: number
37
+ viewport: ToastViewportElement | null
38
+ onViewportChange(viewport: ToastViewportElement): void
39
+ onToastAdd(): void
40
+ onToastRemove(): void
41
+ isFocusedToastEscapeKeyDownRef: React.MutableRefObject<boolean>
42
+ isClosePausedRef: React.MutableRefObject<boolean>
43
+ }
44
+
45
+ type ScopedProps<P> = P & { __scopeToast?: Scope }
46
+ // const [createToastContext, createToastScope] = createContextScope('Toast', [createCollectionScope])
47
+ const [ToastProviderProvider, useToastProviderContext] = [] as any
48
+ // createToastContext<ToastProviderContextValue>(PROVIDER_NAME)
49
+
50
+ interface ToastProviderProps {
51
+ children?: React.ReactNode
52
+ /**
53
+ * An author-localized label for each toast. Used to help screen reader users
54
+ * associate the interruption with a toast.
55
+ * @defaultValue 'Notification'
56
+ */
57
+ label?: string
58
+ /**
59
+ * Time in milliseconds that each toast should remain visible for.
60
+ * @defaultValue 5000
61
+ */
62
+ duration?: number
63
+ /**
64
+ * Direction of pointer swipe that should close the toast.
65
+ * @defaultValue 'right'
66
+ */
67
+ swipeDirection?: SwipeDirection
68
+ /**
69
+ * Distance in pixels that the swipe must pass before a close is triggered.
70
+ * @defaultValue 50
71
+ */
72
+ swipeThreshold?: number
73
+ }
74
+
75
+ const ToastProvider: React.FC<ToastProviderProps> = (props: ScopedProps<ToastProviderProps>) => {
76
+ const {
77
+ __scopeToast,
78
+ label = 'Notification',
79
+ duration = 5000,
80
+ swipeDirection = 'right',
81
+ swipeThreshold = 50,
82
+ children,
83
+ } = props
84
+ const [viewport, setViewport] = React.useState<ToastViewportElement | null>(null)
85
+ const [toastCount, setToastCount] = React.useState(0)
86
+ const isFocusedToastEscapeKeyDownRef = React.useRef(false)
87
+ const isClosePausedRef = React.useRef(false)
88
+ return (
89
+ // <Collection.Provider scope={__scopeToast}>
90
+ <ToastProviderProvider
91
+ scope={__scopeToast}
92
+ label={label}
93
+ duration={duration}
94
+ swipeDirection={swipeDirection}
95
+ swipeThreshold={swipeThreshold}
96
+ toastCount={toastCount}
97
+ viewport={viewport}
98
+ onViewportChange={setViewport}
99
+ onToastAdd={React.useCallback(() => setToastCount((prevCount) => prevCount + 1), [])}
100
+ onToastRemove={React.useCallback(() => setToastCount((prevCount) => prevCount - 1), [])}
101
+ isFocusedToastEscapeKeyDownRef={isFocusedToastEscapeKeyDownRef}
102
+ isClosePausedRef={isClosePausedRef}
103
+ >
104
+ {children}
105
+ </ToastProviderProvider>
106
+ // </Collection.Provider>
107
+ )
108
+ }
109
+
110
+ ToastProvider.propTypes = {
111
+ label(props) {
112
+ if (props.label && typeof props.label === 'string' && !props.label.trim()) {
113
+ const error = `Invalid prop \`label\` supplied to \`${PROVIDER_NAME}\`. Expected non-empty \`string\`.`
114
+ return new Error(error)
115
+ }
116
+ return null
117
+ },
118
+ }
119
+
120
+ ToastProvider.displayName = PROVIDER_NAME
121
+
122
+ /* -------------------------------------------------------------------------------------------------
123
+ * ToastViewport
124
+ * -----------------------------------------------------------------------------------------------*/
125
+
126
+ const VIEWPORT_NAME = 'ToastViewport'
127
+ const VIEWPORT_DEFAULT_HOTKEY = ['F8']
128
+ const VIEWPORT_PAUSE = 'toast.viewportPause'
129
+ const VIEWPORT_RESUME = 'toast.viewportResume'
130
+
131
+ type ToastViewportElement = TamaguiElement
132
+ type PrimitiveOrderedListProps = any //Radix.ComponentPropsWithoutRef<typeof Primitive.ol>
133
+ interface ToastViewportProps extends PrimitiveOrderedListProps {
134
+ /**
135
+ * The keys to use as the keyboard shortcut that will move focus to the toast viewport.
136
+ * @defaultValue ['F8']
137
+ */
138
+ hotkey?: string[]
139
+ /**
140
+ * An author-localized label for the toast viewport to provide context for screen reader users
141
+ * when navigating page landmarks. The available `{hotkey}` placeholder will be replaced for you.
142
+ * @defaultValue 'Notifications ({hotkey})'
143
+ */
144
+ label?: string
145
+ }
146
+
147
+ const ToastViewport = React.forwardRef<ToastViewportElement, ToastViewportProps>(
148
+ (props: ScopedProps<ToastViewportProps>, forwardedRef) => {
149
+ const {
150
+ __scopeToast,
151
+ hotkey = VIEWPORT_DEFAULT_HOTKEY,
152
+ label = 'Notifications ({hotkey})',
153
+ ...viewportProps
154
+ } = props
155
+ const context = useToastProviderContext(VIEWPORT_NAME, __scopeToast)
156
+ // const getItems = useCollection(__scopeToast)
157
+ const wrapperRef = React.useRef<HTMLDivElement>(null)
158
+ const headFocusProxyRef = React.useRef<FocusProxyElement>(null)
159
+ const tailFocusProxyRef = React.useRef<FocusProxyElement>(null)
160
+ const ref = React.useRef<ToastViewportElement>(null)
161
+ const composedRefs = useComposedRefs(forwardedRef, ref, context.onViewportChange)
162
+ const hotkeyLabel = hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, '')
163
+ const hasToasts = context.toastCount > 0
164
+
165
+ React.useEffect(() => {
166
+ const handleKeyDown = (event: KeyboardEvent) => {
167
+ // we use `event.code` as it is consistent regardless of meta keys that were pressed.
168
+ // for example, `event.key` for `Control+Alt+t` is `†` and `t !== †`
169
+ const isHotkeyPressed = hotkey.every((key) => (event as any)[key] || event.code === key)
170
+ if (isHotkeyPressed) ref.current?.focus()
171
+ }
172
+ document.addEventListener('keydown', handleKeyDown)
173
+ return () => document.removeEventListener('keydown', handleKeyDown)
174
+ }, [hotkey])
175
+
176
+ React.useEffect(() => {
177
+ const wrapper = wrapperRef.current
178
+ const viewport = ref.current
179
+ if (wrapper && viewport) {
180
+ const handlePause = () => {
181
+ const pauseEvent = new CustomEvent(VIEWPORT_PAUSE)
182
+ // viewport.dispatchEvent(pauseEvent)
183
+ context.isClosePausedRef.current = true
184
+ }
185
+
186
+ const handleResume = () => {
187
+ const resumeEvent = new CustomEvent(VIEWPORT_RESUME)
188
+ // viewport.dispatchEvent(resumeEvent)
189
+ context.isClosePausedRef.current = false
190
+ }
191
+
192
+ // Toasts are not in the viewport React tree so we need to bind DOM events
193
+ wrapper.addEventListener('focusin', handlePause)
194
+ wrapper.addEventListener('focusout', handleResume)
195
+ wrapper.addEventListener('pointerenter', handlePause)
196
+ wrapper.addEventListener('pointerleave', handleResume)
197
+ window.addEventListener('blur', handlePause)
198
+ window.addEventListener('focus', handleResume)
199
+ return () => {
200
+ wrapper.removeEventListener('focusin', handlePause)
201
+ wrapper.removeEventListener('focusout', handleResume)
202
+ wrapper.removeEventListener('pointerenter', handlePause)
203
+ wrapper.removeEventListener('pointerleave', handleResume)
204
+ window.removeEventListener('blur', handlePause)
205
+ window.removeEventListener('focus', handleResume)
206
+ }
207
+ }
208
+ }, [context.isClosePausedRef])
209
+
210
+ // const getSortedTabbableCandidates = React.useCallback(
211
+ // ({ tabbingDirection }: { tabbingDirection: 'forwards' | 'backwards' }) => {
212
+ // const toastItems = getItems()
213
+ // const tabbableCandidates = toastItems.map((toastItem) => {
214
+ // const toastNode = toastItem.ref.current!
215
+ // const toastTabbableCandidates = [toastNode, ...getTabbableCandidates(toastNode)]
216
+ // return tabbingDirection === 'forwards'
217
+ // ? toastTabbableCandidates
218
+ // : toastTabbableCandidates.reverse()
219
+ // })
220
+ // return (
221
+ // tabbingDirection === 'forwards' ? tabbableCandidates.reverse() : tabbableCandidates
222
+ // ).flat()
223
+ // },
224
+ // [getItems]
225
+ // )
226
+
227
+ React.useEffect(
228
+ () => {
229
+ const viewport = ref.current
230
+ // We programmatically manage tabbing as we are unable to influence
231
+ // the source order with portals, this allows us to reverse the
232
+ // tab order so that it runs from most recent toast to least
233
+ if (viewport) {
234
+ const handleKeyDown = (event: KeyboardEvent) => {
235
+ const isMetaKey = event.altKey || event.ctrlKey || event.metaKey
236
+ const isTabKey = event.key === 'Tab' && !isMetaKey
237
+
238
+ if (isTabKey) {
239
+ const focusedElement = document.activeElement
240
+ const isTabbingBackwards = event.shiftKey
241
+ const targetIsViewport = event.target === viewport
242
+
243
+ // If we're back tabbing after jumping to the viewport then we simply
244
+ // proxy focus out to the preceding document
245
+ if (targetIsViewport && isTabbingBackwards) {
246
+ headFocusProxyRef.current?.focus()
247
+ return
248
+ }
249
+
250
+ const tabbingDirection = isTabbingBackwards ? 'backwards' : 'forwards'
251
+ // const sortedCandidates = getSortedTabbableCandidates({ tabbingDirection })
252
+ // const index = sortedCandidates.findIndex((candidate) => candidate === focusedElement)
253
+ // if (focusFirst(sortedCandidates.slice(index + 1))) {
254
+ // event.preventDefault()
255
+ // } else {
256
+ // // If we can't focus that means we're at the edges so we
257
+ // // proxy to the corresponding exit point and let the browser handle
258
+ // // tab/shift+tab keypress and implicitly pass focus to the next valid element in the document
259
+ // isTabbingBackwards
260
+ // ? headFocusProxyRef.current?.focus()
261
+ // : tailFocusProxyRef.current?.focus()
262
+ // }
263
+ }
264
+ }
265
+
266
+ // Toasts are not in the viewport React tree so we need to bind DOM events
267
+ // viewport.addEventListener('keydown', handleKeyDown)
268
+ return () => {
269
+ // viewport.removeEventListener('keydown', handleKeyDown)
270
+ }
271
+ }
272
+ },
273
+ [
274
+ // getItems, getSortedTabbableCandidates
275
+ ]
276
+ )
277
+
278
+ return null
279
+ // return (
280
+ // <DismissableLayer.Branch
281
+ // ref={wrapperRef}
282
+ // role="region"
283
+ // aria-label={label.replace('{hotkey}', hotkeyLabel)}
284
+ // // Ensure virtual cursor from landmarks menus triggers focus/blur for pause/resume
285
+ // tabIndex={-1}
286
+ // // incase list has size when empty (e.g. padding), we remove pointer events so
287
+ // // it doesn't prevent interactions with page elements that it overlays
288
+ // style={{ pointerEvents: hasToasts ? undefined : 'none' }}
289
+ // >
290
+ // {hasToasts && (
291
+ // <FocusProxy
292
+ // ref={headFocusProxyRef}
293
+ // onFocusFromOutsideViewport={() => {
294
+ // const tabbableCandidates = getSortedTabbableCandidates({
295
+ // tabbingDirection: 'forwards',
296
+ // })
297
+ // focusFirst(tabbableCandidates)
298
+ // }}
299
+ // />
300
+ // )}
301
+ // {/**
302
+ // * tabindex on the the list so that it can be focused when items are removed. we focus
303
+ // * the list instead of the viewport so it announces number of items remaining.
304
+ // */}
305
+ // <Slot scope={__scopeToast}>
306
+ // <Primitive.ol tabIndex={-1} {...viewportProps} ref={composedRefs} />
307
+ // </Slot>
308
+ // {hasToasts && (
309
+ // <FocusProxy
310
+ // ref={tailFocusProxyRef}
311
+ // onFocusFromOutsideViewport={() => {
312
+ // const tabbableCandidates = getSortedTabbableCandidates({
313
+ // tabbingDirection: 'backwards',
314
+ // })
315
+ // focusFirst(tabbableCandidates)
316
+ // }}
317
+ // />
318
+ // )}
319
+ // </DismissableLayer.Branch>
320
+ // )
321
+ }
322
+ )
323
+
324
+ ToastViewport.displayName = VIEWPORT_NAME
325
+
326
+ /* -----------------------------------------------------------------------------------------------*/
327
+
328
+ const FOCUS_PROXY_NAME = 'ToastFocusProxy'
329
+
330
+ type FocusProxyElement = React.ElementRef<typeof VisuallyHidden>
331
+ type VisuallyHiddenProps = any // Radix.ComponentPropsWithoutRef<typeof VisuallyHidden>
332
+ interface FocusProxyProps extends VisuallyHiddenProps {
333
+ onFocusFromOutsideViewport(): void
334
+ }
335
+
336
+ const FocusProxy = React.forwardRef<FocusProxyElement, ScopedProps<FocusProxyProps>>(
337
+ (props, forwardedRef) => {
338
+ const { __scopeToast, onFocusFromOutsideViewport, ...proxyProps } = props
339
+ const context = useToastProviderContext(FOCUS_PROXY_NAME, __scopeToast)
340
+
341
+ return (
342
+ <VisuallyHidden
343
+ aria-hidden
344
+ // @ts-ignore
345
+ tabIndex={0}
346
+ {...proxyProps}
347
+ ref={forwardedRef}
348
+ // Avoid page scrolling when focus is on the focus proxy
349
+ position={isWeb ? ('fixed' as any) : 'absolute'}
350
+ onFocus={(event) => {
351
+ const prevFocusedElement = event.relatedTarget as HTMLElement | null
352
+ const isFocusFromOutsideViewport = !context.viewport?.contains(prevFocusedElement)
353
+ if (isFocusFromOutsideViewport) onFocusFromOutsideViewport()
354
+ }}
355
+ />
356
+ )
357
+ }
358
+ )
359
+
360
+ FocusProxy.displayName = FOCUS_PROXY_NAME
361
+
362
+ /* -------------------------------------------------------------------------------------------------
363
+ * Toast
364
+ * -----------------------------------------------------------------------------------------------*/
365
+
366
+ const TOAST_NAME = 'Toast'
367
+ const TOAST_SWIPE_START = 'toast.swipeStart'
368
+ const TOAST_SWIPE_MOVE = 'toast.swipeMove'
369
+ const TOAST_SWIPE_CANCEL = 'toast.swipeCancel'
370
+ const TOAST_SWIPE_END = 'toast.swipeEnd'
371
+
372
+ type ToastElement = ToastImplElement
373
+ interface ToastProps extends Omit<ToastImplProps, keyof ToastImplPrivateProps> {
374
+ open?: boolean
375
+ defaultOpen?: boolean
376
+ onOpenChange?(open: boolean): void
377
+ /**
378
+ * Used to force mounting when more control is needed. Useful when
379
+ * controlling animation with React animation libraries.
380
+ */
381
+ forceMount?: true
382
+ }
383
+
384
+ const Toast = React.forwardRef<ToastElement, ToastProps>(
385
+ (props: ScopedProps<ToastProps>, forwardedRef) => {
386
+ const { forceMount, open: openProp, defaultOpen, onOpenChange, ...toastProps } = props
387
+ const [open, setOpen] = useControllableState({
388
+ prop: openProp,
389
+ defaultProp: defaultOpen || true,
390
+ onChange: onOpenChange,
391
+ })
392
+ return (
393
+ // <Presence present={forceMount || open}>
394
+ <ToastImpl
395
+ open={open}
396
+ {...toastProps}
397
+ ref={forwardedRef}
398
+ onClose={() => setOpen(false)}
399
+ // onSwipeStart={composeEventHandlers(props.onSwipeStart, (event) => {
400
+ // event.currentTarget.setAttribute('data-swipe', 'start')
401
+ // })}
402
+ // onSwipeMove={composeEventHandlers(props.onSwipeMove, (event) => {
403
+ // const { x, y } = event.detail.delta
404
+ // event.currentTarget.setAttribute('data-swipe', 'move')
405
+ // event.currentTarget.style.setProperty('--radix-toast-swipe-move-x', `${x}px`)
406
+ // event.currentTarget.style.setProperty('--radix-toast-swipe-move-y', `${y}px`)
407
+ // })}
408
+ // onSwipeCancel={composeEventHandlers(props.onSwipeCancel, (event) => {
409
+ // event.currentTarget.setAttribute('data-swipe', 'cancel')
410
+ // event.currentTarget.style.removeProperty('--radix-toast-swipe-move-x')
411
+ // event.currentTarget.style.removeProperty('--radix-toast-swipe-move-y')
412
+ // event.currentTarget.style.removeProperty('--radix-toast-swipe-end-x')
413
+ // event.currentTarget.style.removeProperty('--radix-toast-swipe-end-y')
414
+ // })}
415
+ // onSwipeEnd={composeEventHandlers(props.onSwipeEnd, (event) => {
416
+ // const { x, y } = event.detail.delta
417
+ // event.currentTarget.setAttribute('data-swipe', 'end')
418
+ // event.currentTarget.style.removeProperty('--radix-toast-swipe-move-x')
419
+ // event.currentTarget.style.removeProperty('--radix-toast-swipe-move-y')
420
+ // event.currentTarget.style.setProperty('--radix-toast-swipe-end-x', `${x}px`)
421
+ // event.currentTarget.style.setProperty('--radix-toast-swipe-end-y', `${y}px`)
422
+ // setOpen(false)
423
+ // })}
424
+ />
425
+ // </Presence>
426
+ )
427
+ }
428
+ )
429
+
430
+ Toast.displayName = TOAST_NAME
431
+
432
+ /* -----------------------------------------------------------------------------------------------*/
433
+
434
+ type SwipeEvent = { currentTarget: EventTarget & ToastElement } & Omit<
435
+ CustomEvent<{ originalEvent: React.PointerEvent; delta: { x: number; y: number } }>,
436
+ 'currentTarget'
437
+ >
438
+
439
+ const [ToastInteractiveProvider, useToastInteractiveContext] = {} as any
440
+ // createToastContext(TOAST_NAME, {
441
+ // isInteractive: false,
442
+ // onClose() {},
443
+ // })
444
+
445
+ type ToastImplElement = TamaguiElement
446
+ type DismissableLayerProps = any //Radix.ComponentPropsWithoutRef<typeof DismissableLayer.Root>
447
+ type ToastImplPrivateProps = { open: boolean; onClose(): void }
448
+ type PrimitiveListItemProps = any //Radix.ComponentPropsWithoutRef<typeof Primitive.li>
449
+ interface ToastImplProps extends ToastImplPrivateProps, PrimitiveListItemProps {
450
+ type?: 'foreground' | 'background'
451
+ /**
452
+ * Time in milliseconds that toast should remain visible for. Overrides value
453
+ * given to `ToastProvider`.
454
+ */
455
+ duration?: number
456
+ onEscapeKeyDown?: DismissableLayerProps['onEscapeKeyDown']
457
+ onSwipeStart?(event: SwipeEvent): void
458
+ onSwipeMove?(event: SwipeEvent): void
459
+ onSwipeCancel?(event: SwipeEvent): void
460
+ onSwipeEnd?(event: SwipeEvent): void
461
+ }
462
+
463
+ const ToastImpl = React.forwardRef<ToastImplElement, ToastImplProps>(
464
+ (props: ScopedProps<ToastImplProps> & { children?: React.ReactNode }, forwardedRef) => {
465
+ const {
466
+ __scopeToast,
467
+ type = 'foreground',
468
+ duration: durationProp,
469
+ open,
470
+ onClose,
471
+ onEscapeKeyDown,
472
+ onSwipeStart,
473
+ onSwipeMove,
474
+ onSwipeCancel,
475
+ onSwipeEnd,
476
+ ...toastProps
477
+ } = props
478
+ const context = useToastProviderContext(TOAST_NAME, __scopeToast)
479
+ const ref = React.useRef<ToastImplElement>(null)
480
+ const composedRefs = useComposedRefs(forwardedRef, ref)
481
+ const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null)
482
+ const swipeDeltaRef = React.useRef<{ x: number; y: number } | null>(null)
483
+ const duration = durationProp || context.duration
484
+ const closeTimerStartTimeRef = React.useRef(0)
485
+ const closeTimerRemainingTimeRef = React.useRef(duration)
486
+ const closeTimerRef = React.useRef(0)
487
+ const { onToastAdd, onToastRemove } = context
488
+ // was useCallbackRef
489
+ const handleClose = useEvent(() => {
490
+ // focus viewport if focus is within toast to read the remaining toast
491
+ // count to SR users and ensure focus isn't lost
492
+ // const isFocusInToast = ref.current?.contains(document.activeElement)
493
+ // if (isFocusInToast) context.viewport?.focus()
494
+ onClose()
495
+ })
496
+
497
+ const startTimer = React.useCallback(
498
+ (duration: number) => {
499
+ if (!duration || duration === Infinity) return
500
+ window.clearTimeout(closeTimerRef.current)
501
+ closeTimerStartTimeRef.current = new Date().getTime()
502
+ closeTimerRef.current = window.setTimeout(handleClose, duration)
503
+ },
504
+ [handleClose]
505
+ )
506
+
507
+ React.useEffect(() => {
508
+ const viewport = context.viewport
509
+ if (viewport) {
510
+ const handleResume = () => {
511
+ startTimer(closeTimerRemainingTimeRef.current)
512
+ }
513
+ const handlePause = () => {
514
+ const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current
515
+ closeTimerRemainingTimeRef.current = closeTimerRemainingTimeRef.current - elapsedTime
516
+ window.clearTimeout(closeTimerRef.current)
517
+ }
518
+ viewport.addEventListener(VIEWPORT_PAUSE, handlePause)
519
+ viewport.addEventListener(VIEWPORT_RESUME, handleResume)
520
+ return () => {
521
+ viewport.removeEventListener(VIEWPORT_PAUSE, handlePause)
522
+ viewport.removeEventListener(VIEWPORT_RESUME, handleResume)
523
+ }
524
+ }
525
+ }, [context.viewport, duration, startTimer])
526
+
527
+ // start timer when toast opens or duration changes.
528
+ // we include `open` in deps because closed !== unmounted when animating
529
+ // so it could reopen before being completely unmounted
530
+ React.useEffect(() => {
531
+ if (open && !context.isClosePausedRef.current) startTimer(duration)
532
+ }, [open, duration, context.isClosePausedRef, startTimer])
533
+
534
+ React.useEffect(() => {
535
+ onToastAdd()
536
+ return () => onToastRemove()
537
+ }, [onToastAdd, onToastRemove])
538
+
539
+ if (!context.viewport) return null
540
+
541
+ return (
542
+ <>
543
+ <ToastAnnounce
544
+ __scopeToast={__scopeToast}
545
+ // Toasts are always role=status to avoid stuttering issues with role=alert in SRs.
546
+ role="status"
547
+ aria-live={type === 'foreground' ? 'assertive' : 'polite'}
548
+ aria-atomic
549
+ >
550
+ {props.children}
551
+ </ToastAnnounce>
552
+
553
+ <ToastInteractiveProvider scope={__scopeToast} isInteractive onClose={handleClose}>
554
+ {ReactDOM.createPortal(
555
+ <div />,
556
+ // <Collection.ItemSlot scope={__scopeToast}>
557
+ // <DismissableLayer.Root
558
+ // asChild
559
+ // onEscapeKeyDown={composeEventHandlers(onEscapeKeyDown, () => {
560
+ // if (!context.isFocusedToastEscapeKeyDownRef.current) handleClose()
561
+ // context.isFocusedToastEscapeKeyDownRef.current = false
562
+ // })}
563
+ // >
564
+ // <Primitive.li
565
+ // // Ensure toasts are announced as status list or status when focused
566
+ // role="status"
567
+ // aria-live="off"
568
+ // aria-atomic
569
+ // tabIndex={0}
570
+ // data-state={open ? 'open' : 'closed'}
571
+ // data-swipe-direction={context.swipeDirection}
572
+ // {...toastProps}
573
+ // ref={composedRefs}
574
+ // style={{ userSelect: 'none', touchAction: 'none', ...props.style }}
575
+ // onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
576
+ // if (event.key !== 'Escape') return
577
+ // onEscapeKeyDown?.(event.nativeEvent)
578
+ // if (!event.nativeEvent.defaultPrevented) {
579
+ // context.isFocusedToastEscapeKeyDownRef.current = true
580
+ // handleClose()
581
+ // }
582
+ // })}
583
+ // onPointerDown={composeEventHandlers(props.onPointerDown, (event) => {
584
+ // if (event.button !== 0) return
585
+ // pointerStartRef.current = { x: event.clientX, y: event.clientY }
586
+ // })}
587
+ // onPointerMove={composeEventHandlers(props.onPointerMove, (event) => {
588
+ // if (!pointerStartRef.current) return
589
+ // const x = event.clientX - pointerStartRef.current.x
590
+ // const y = event.clientY - pointerStartRef.current.y
591
+ // const hasSwipeMoveStarted = Boolean(swipeDeltaRef.current)
592
+ // const isHorizontalSwipe = ['left', 'right'].includes(context.swipeDirection)
593
+ // const clamp = ['left', 'up'].includes(context.swipeDirection)
594
+ // ? Math.min
595
+ // : Math.max
596
+ // const clampedX = isHorizontalSwipe ? clamp(0, x) : 0
597
+ // const clampedY = !isHorizontalSwipe ? clamp(0, y) : 0
598
+ // const moveStartBuffer = event.pointerType === 'touch' ? 10 : 2
599
+ // const delta = { x: clampedX, y: clampedY }
600
+ // const eventDetail = { originalEvent: event, delta }
601
+ // if (hasSwipeMoveStarted) {
602
+ // swipeDeltaRef.current = delta
603
+ // handleAndDispatchCustomEvent(TOAST_SWIPE_MOVE, onSwipeMove, eventDetail, {
604
+ // discrete: false,
605
+ // })
606
+ // } else if (isDeltaInDirection(delta, context.swipeDirection, moveStartBuffer)) {
607
+ // swipeDeltaRef.current = delta
608
+ // handleAndDispatchCustomEvent(TOAST_SWIPE_START, onSwipeStart, eventDetail, {
609
+ // discrete: false,
610
+ // })
611
+ // ;(event.target as HTMLElement).setPointerCapture(event.pointerId)
612
+ // } else if (Math.abs(x) > moveStartBuffer || Math.abs(y) > moveStartBuffer) {
613
+ // // User is swiping in wrong direction so we disable swipe gesture
614
+ // // for the current pointer down interaction
615
+ // pointerStartRef.current = null
616
+ // }
617
+ // })}
618
+ // onPointerUp={composeEventHandlers(props.onPointerUp, (event) => {
619
+ // const delta = swipeDeltaRef.current
620
+ // ;(event.target as HTMLElement).releasePointerCapture(event.pointerId)
621
+ // swipeDeltaRef.current = null
622
+ // pointerStartRef.current = null
623
+ // if (delta) {
624
+ // const toast = event.currentTarget
625
+ // const eventDetail = { originalEvent: event, delta }
626
+ // if (isDeltaInDirection(delta, context.swipeDirection, context.swipeThreshold)) {
627
+ // handleAndDispatchCustomEvent(TOAST_SWIPE_END, onSwipeEnd, eventDetail, {
628
+ // discrete: true,
629
+ // })
630
+ // } else {
631
+ // handleAndDispatchCustomEvent(TOAST_SWIPE_CANCEL, onSwipeCancel, eventDetail, {
632
+ // discrete: true,
633
+ // })
634
+ // }
635
+ // // Prevent click event from triggering on items within the toast when
636
+ // // pointer up is part of a swipe gesture
637
+ // toast.addEventListener('click', (event) => event.preventDefault(), {
638
+ // once: true,
639
+ // })
640
+ // }
641
+ // })}
642
+ // />
643
+ // </DismissableLayer.Root>,
644
+ // </Collection.ItemSlot>,
645
+ context.viewport
646
+ )}
647
+ </ToastInteractiveProvider>
648
+ </>
649
+ )
650
+ }
651
+ )
652
+
653
+ ToastImpl.propTypes = {
654
+ type(props) {
655
+ if (props.type && !['foreground', 'background'].includes(props.type)) {
656
+ const error = `Invalid prop \`type\` supplied to \`${TOAST_NAME}\`. Expected \`foreground | background\`.`
657
+ return new Error(error)
658
+ }
659
+ return null
660
+ },
661
+ }
662
+
663
+ /* -----------------------------------------------------------------------------------------------*/
664
+
665
+ type ToastAnnounceProps = any
666
+ // extends React.ComponentPropsWithoutRef<'div'>,
667
+ // ScopedProps<{ children?: ToastImplProps['children'] }> {}
668
+
669
+ const ToastAnnounce: React.FC<ToastAnnounceProps> = (props: ScopedProps<ToastAnnounceProps>) => {
670
+ const { __scopeToast, children, ...announceProps } = props
671
+ const context = useToastProviderContext(TOAST_NAME, __scopeToast)
672
+ const [renderAnnounceText, setRenderAnnounceText] = React.useState(false)
673
+ const [isAnnounced, setIsAnnounced] = React.useState(false)
674
+ const [fragment, setFragment] = React.useState<DocumentFragment>()
675
+ const [rootFragmentNode, setRootFragmentNode] = React.useState<HTMLDivElement | null>(null)
676
+
677
+ // We portal children into a document fragment so that we can extract the bare text nodes
678
+ // before rendering to the DOM. This avoids issues with duplicate `children`
679
+ // and animation libraries when composing via `asChild`.
680
+ const announceTextContent = React.useMemo(() => {
681
+ return rootFragmentNode ? getAnnounceTextContent(rootFragmentNode) : null
682
+ }, [rootFragmentNode])
683
+
684
+ // setting the fragment in `useIsomorphicLayoutEffect` as `DocumentFragment` doesn't exist on the server
685
+ useIsomorphicLayoutEffect(() => {
686
+ setFragment(new DocumentFragment())
687
+ }, [])
688
+
689
+ // render text content in the next frame to ensure toast is announced in NVDA
690
+ useNextFrame(() => setRenderAnnounceText(true))
691
+
692
+ React.useEffect(() => {
693
+ const timer = window.setTimeout(() => setIsAnnounced(true), 1000)
694
+ return () => window.clearTimeout(timer)
695
+ }, [])
696
+
697
+ return isAnnounced ? null : (
698
+ <>
699
+ {fragment && (
700
+ // @ts-ignore
701
+ <Portal container={fragment as any} ref={setRootFragmentNode}>
702
+ {context.label} {children}
703
+ </Portal>
704
+ )}
705
+ <Portal asChild>
706
+ <VisuallyHidden {...announceProps}>
707
+ {renderAnnounceText && announceTextContent}
708
+ </VisuallyHidden>
709
+ </Portal>
710
+ </>
711
+ )
712
+ }
713
+
714
+ /* -------------------------------------------------------------------------------------------------
715
+ * ToastTitle
716
+ * -----------------------------------------------------------------------------------------------*/
717
+
718
+ const TITLE_NAME = 'ToastTitle'
719
+
720
+ type ToastTitleElement = TamaguiElement
721
+ type PrimitiveDivProps = any // Radix.ComponentPropsWithoutRef<typeof YStack>
722
+ interface ToastTitleProps extends PrimitiveDivProps {}
723
+
724
+ const ToastTitle = React.forwardRef<ToastTitleElement, ToastTitleProps>(
725
+ (props: ScopedProps<ToastTitleProps>, forwardedRef) => {
726
+ const { __scopeToast, ...titleProps } = props
727
+ return <YStack {...titleProps} ref={forwardedRef} />
728
+ }
729
+ )
730
+
731
+ ToastTitle.displayName = TITLE_NAME
732
+
733
+ /* -------------------------------------------------------------------------------------------------
734
+ * ToastDescription
735
+ * -----------------------------------------------------------------------------------------------*/
736
+
737
+ const DESCRIPTION_NAME = 'ToastDescription'
738
+
739
+ type ToastDescriptionElement = React.ElementRef<typeof YStack>
740
+ interface ToastDescriptionProps extends PrimitiveDivProps {}
741
+
742
+ const ToastDescription = React.forwardRef<ToastDescriptionElement, ToastDescriptionProps>(
743
+ (props: ScopedProps<ToastDescriptionProps>, forwardedRef) => {
744
+ const { __scopeToast, ...descriptionProps } = props
745
+ return <YStack {...descriptionProps} ref={forwardedRef} />
746
+ }
747
+ )
748
+
749
+ ToastDescription.displayName = DESCRIPTION_NAME
750
+
751
+ /* -------------------------------------------------------------------------------------------------
752
+ * ToastAction
753
+ * -----------------------------------------------------------------------------------------------*/
754
+
755
+ const ACTION_NAME = 'ToastAction'
756
+
757
+ type ToastActionElement = ToastCloseElement
758
+ interface ToastActionProps extends ToastCloseProps {
759
+ /**
760
+ * A short description for an alternate way to carry out the action. For screen reader users
761
+ * who will not be able to navigate to the button easily/quickly.
762
+ * @example <ToastAction altText="Goto account settings to updgrade">Upgrade</ToastAction>
763
+ * @example <ToastAction altText="Undo (Alt+U)">Undo</ToastAction>
764
+ */
765
+ altText: string
766
+ }
767
+
768
+ const ToastAction = React.forwardRef<ToastActionElement, ToastActionProps>(
769
+ (props: ScopedProps<ToastActionProps>, forwardedRef) => {
770
+ const { altText, ...actionProps } = props
771
+ const context = useToastInteractiveContext(ACTION_NAME, props.__scopeToast)
772
+ if (!altText) return null
773
+ return context.isInteractive ? (
774
+ <ToastClose {...actionProps} ref={forwardedRef} />
775
+ ) : (
776
+ <span>{altText}</span>
777
+ )
778
+ }
779
+ )
780
+
781
+ ToastAction.propTypes = {
782
+ altText(props) {
783
+ if (!props.altText) {
784
+ return new Error(`Missing prop \`altText\` expected on \`${ACTION_NAME}\``)
785
+ }
786
+ return null
787
+ },
788
+ }
789
+
790
+ ToastAction.displayName = ACTION_NAME
791
+
792
+ /* -------------------------------------------------------------------------------------------------
793
+ * ToastClose
794
+ * -----------------------------------------------------------------------------------------------*/
795
+
796
+ const CLOSE_NAME = 'ToastClose'
797
+
798
+ type ToastCloseElement = TamaguiElement
799
+ type PrimitiveButtonProps = any //Radix.ComponentPropsWithoutRef<typeof Primitive.button>
800
+ interface ToastCloseProps extends PrimitiveButtonProps {}
801
+
802
+ const ToastClose = React.forwardRef<ToastCloseElement, ToastCloseProps>(
803
+ (props: ScopedProps<ToastCloseProps>, forwardedRef) => {
804
+ const { __scopeToast, ...closeProps } = props
805
+ const interactiveContext = useToastInteractiveContext(CLOSE_NAME, __scopeToast)
806
+ return interactiveContext.isInteractive ? (
807
+ <YStack
808
+ tag="button"
809
+ {...closeProps}
810
+ ref={forwardedRef}
811
+ onPress={composeEventHandlers(props.onPress, interactiveContext.onClose)}
812
+ />
813
+ ) : null
814
+ }
815
+ )
816
+
817
+ ToastClose.displayName = CLOSE_NAME
818
+
819
+ /* ---------------------------------------------------------------------------------------------- */
820
+
821
+ function handleAndDispatchCustomEvent<
822
+ E extends CustomEvent,
823
+ ReactEvent extends React.SyntheticEvent
824
+ >(
825
+ name: string,
826
+ handler: ((event: E) => void) | undefined,
827
+ detail: { originalEvent: ReactEvent } & (E extends CustomEvent<infer D> ? D : never),
828
+ { discrete }: { discrete: boolean }
829
+ ) {
830
+ const currentTarget = detail.originalEvent.currentTarget as HTMLElement
831
+ const event = new CustomEvent(name, { bubbles: true, cancelable: true, detail })
832
+ if (handler) currentTarget.addEventListener(name, handler as EventListener, { once: true })
833
+
834
+ if (discrete) {
835
+ // dispatchDiscreteCustomEvent(currentTarget, event)
836
+ } else {
837
+ currentTarget.dispatchEvent(event)
838
+ }
839
+ }
840
+
841
+ const isDeltaInDirection = (
842
+ delta: { x: number; y: number },
843
+ direction: SwipeDirection,
844
+ threshold = 0
845
+ ) => {
846
+ const deltaX = Math.abs(delta.x)
847
+ const deltaY = Math.abs(delta.y)
848
+ const isDeltaX = deltaX > deltaY
849
+ if (direction === 'left' || direction === 'right') {
850
+ return isDeltaX && deltaX > threshold
851
+ } else {
852
+ return !isDeltaX && deltaY > threshold
853
+ }
854
+ }
855
+
856
+ function useNextFrame(callback = () => {}) {
857
+ // useCallbackRef
858
+ const fn = useEvent(callback)
859
+ useIsomorphicLayoutEffect(() => {
860
+ let raf1 = 0
861
+ let raf2 = 0
862
+ raf1 = window.requestAnimationFrame(() => (raf2 = window.requestAnimationFrame(fn)))
863
+ return () => {
864
+ window.cancelAnimationFrame(raf1)
865
+ window.cancelAnimationFrame(raf2)
866
+ }
867
+ }, [fn])
868
+ }
869
+
870
+ function getAnnounceTextContent(container: HTMLElement) {
871
+ const textContent: string[] = []
872
+ const childNodes = Array.from(container.childNodes)
873
+
874
+ childNodes.forEach((node) => {
875
+ if (node.nodeType === node.TEXT_NODE && node.textContent) textContent.push(node.textContent)
876
+ if (isHTMLElement(node)) {
877
+ const isHidden = node.ariaHidden || node.hidden || node.style.display === 'none'
878
+ if (!isHidden) textContent.push(...getAnnounceTextContent(node))
879
+ }
880
+ })
881
+
882
+ // We return a collection of text rather than a single concatenated string.
883
+ // This allows SR VO to naturally pause break between nodes while announcing.
884
+ return textContent
885
+ }
886
+
887
+ function isHTMLElement(node: any): node is HTMLElement {
888
+ return node.nodeType === node.ELEMENT_NODE
889
+ }
890
+
891
+ /**
892
+ * Returns a list of potential tabbable candidates.
893
+ *
894
+ * NOTE: This is only a close approximation. For example it doesn't take into account cases like when
895
+ * elements are not visible. This cannot be worked out easily by just reading a property, but rather
896
+ * necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
897
+ *
898
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
899
+ * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
900
+ */
901
+ function getTabbableCandidates(container: HTMLElement) {
902
+ const nodes: HTMLElement[] = []
903
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
904
+ acceptNode: (node: any) => {
905
+ const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'
906
+ if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP
907
+ // `.tabIndex` is not the same as the `tabindex` attribute. It works on the
908
+ // runtime's understanding of tabbability, so this automatically accounts
909
+ // for any kind of element that could be tabbed to.
910
+ return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
911
+ },
912
+ })
913
+ while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement)
914
+ // we do not take into account the order of nodes with positive `tabIndex` as it
915
+ // hinders accessibility to have tab order different from visual order.
916
+ return nodes
917
+ }
918
+
919
+ function focusFirst(candidates: HTMLElement[]) {
920
+ const previouslyFocusedElement = document.activeElement
921
+ return candidates.some((candidate) => {
922
+ // if focus is already where we want to go, we don't want to keep going through the candidates
923
+ if (candidate === previouslyFocusedElement) return true
924
+ candidate.focus()
925
+ return document.activeElement !== previouslyFocusedElement
926
+ })
927
+ }
928
+
929
+ const Provider = ToastProvider
930
+ const Viewport = ToastViewport
931
+ const Root = Toast
932
+ const Title = ToastTitle
933
+ const Description = ToastDescription
934
+ const Action = ToastAction
935
+ const Close = ToastClose
936
+
937
+ export {
938
+ // createToastScope,
939
+ //
940
+ ToastProvider,
941
+ ToastViewport,
942
+ Toast,
943
+ ToastTitle,
944
+ ToastDescription,
945
+ ToastAction,
946
+ ToastClose,
947
+ //
948
+ Provider,
949
+ Viewport,
950
+ Root,
951
+ Title,
952
+ Description,
953
+ Action,
954
+ Close,
955
+ }
956
+ export type {
957
+ ToastProviderProps,
958
+ ToastViewportProps,
959
+ ToastProps,
960
+ ToastTitleProps,
961
+ ToastDescriptionProps,
962
+ ToastActionProps,
963
+ ToastCloseProps,
964
+ }