@hanzogui/toast 3.0.2 → 7.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.
Files changed (167) hide show
  1. package/package.json +42 -25
  2. package/src/Toast.tsx +5 -5
  3. package/src/ToastAnnounce.tsx +2 -2
  4. package/src/ToastComposable.tsx +1380 -0
  5. package/src/ToastImpl.tsx +19 -7
  6. package/src/ToastItemFrame.tsx +136 -0
  7. package/src/ToastPortal.tsx +2 -2
  8. package/src/ToastProvider.tsx +13 -6
  9. package/src/ToastState.ts +398 -0
  10. package/src/ToastViewport.tsx +11 -10
  11. package/src/Toaster.tsx +181 -0
  12. package/src/createNativeToast.native.tsx +4 -0
  13. package/src/createNativeToast.tsx +18 -24
  14. package/src/dispatchNativeToast.ts +43 -0
  15. package/src/useAnimatedDragGesture.native.ts +255 -0
  16. package/src/useAnimatedDragGesture.ts +319 -0
  17. package/src/useReducedMotion.ts +59 -0
  18. package/src/useToastAnimations.ts +377 -0
  19. package/src/v2.ts +31 -0
  20. package/types/Toast.d.ts +36 -36
  21. package/types/ToastAnnounce.d.ts +4 -4
  22. package/types/ToastComposable.d.ts +199 -0
  23. package/types/ToastImperative.d.ts +2 -2
  24. package/types/ToastImpl.d.ts +12 -12
  25. package/types/ToastItemFrame.d.ts +25 -0
  26. package/types/ToastProvider.d.ts +8 -8
  27. package/types/ToastState.d.ts +179 -0
  28. package/types/ToastViewport.d.ts +12 -12
  29. package/types/Toaster.d.ts +112 -0
  30. package/types/createNativeToast.d.ts +5 -0
  31. package/types/createNativeToast.native.d.ts +1 -0
  32. package/types/dispatchNativeToast.d.ts +12 -0
  33. package/types/useAnimatedDragGesture.d.ts +33 -0
  34. package/types/useAnimatedDragGesture.native.d.ts +33 -0
  35. package/types/useReducedMotion.d.ts +6 -0
  36. package/types/useToastAnimations.d.ts +50 -0
  37. package/types/v2.d.ts +10 -0
  38. package/v2/index.cjs +2 -0
  39. package/v2/index.js +2 -0
  40. package/v2/index.native.cjs +2 -0
  41. package/v2/index.native.js +2 -0
  42. package/LICENSE +0 -21
  43. package/dist/cjs/Toast.cjs +0 -170
  44. package/dist/cjs/Toast.native.js +0 -174
  45. package/dist/cjs/Toast.native.js.map +0 -1
  46. package/dist/cjs/ToastAnnounce.cjs +0 -97
  47. package/dist/cjs/ToastAnnounce.native.js +0 -105
  48. package/dist/cjs/ToastAnnounce.native.js.map +0 -1
  49. package/dist/cjs/ToastImperative.cjs +0 -100
  50. package/dist/cjs/ToastImperative.native.js +0 -122
  51. package/dist/cjs/ToastImperative.native.js.map +0 -1
  52. package/dist/cjs/ToastImpl.cjs +0 -292
  53. package/dist/cjs/ToastImpl.native.js +0 -326
  54. package/dist/cjs/ToastImpl.native.js.map +0 -1
  55. package/dist/cjs/ToastPortal.cjs +0 -44
  56. package/dist/cjs/ToastPortal.native.js +0 -47
  57. package/dist/cjs/ToastPortal.native.js.map +0 -1
  58. package/dist/cjs/ToastProvider.cjs +0 -146
  59. package/dist/cjs/ToastProvider.native.js +0 -159
  60. package/dist/cjs/ToastProvider.native.js.map +0 -1
  61. package/dist/cjs/ToastViewport.cjs +0 -278
  62. package/dist/cjs/ToastViewport.native.js +0 -315
  63. package/dist/cjs/ToastViewport.native.js.map +0 -1
  64. package/dist/cjs/constants.cjs +0 -28
  65. package/dist/cjs/constants.native.js +0 -31
  66. package/dist/cjs/constants.native.js.map +0 -1
  67. package/dist/cjs/createNativeToast.cjs +0 -51
  68. package/dist/cjs/createNativeToast.native.js +0 -48
  69. package/dist/cjs/createNativeToast.native.js.map +0 -1
  70. package/dist/cjs/index.cjs +0 -18
  71. package/dist/cjs/index.native.js +0 -21
  72. package/dist/cjs/index.native.js.map +0 -1
  73. package/dist/cjs/types.cjs +0 -16
  74. package/dist/cjs/types.native.js +0 -19
  75. package/dist/cjs/types.native.js.map +0 -1
  76. package/dist/esm/Toast.mjs +0 -131
  77. package/dist/esm/Toast.mjs.map +0 -1
  78. package/dist/esm/Toast.native.js +0 -132
  79. package/dist/esm/Toast.native.js.map +0 -1
  80. package/dist/esm/ToastAnnounce.mjs +0 -62
  81. package/dist/esm/ToastAnnounce.mjs.map +0 -1
  82. package/dist/esm/ToastAnnounce.native.js +0 -67
  83. package/dist/esm/ToastAnnounce.native.js.map +0 -1
  84. package/dist/esm/ToastImperative.mjs +0 -63
  85. package/dist/esm/ToastImperative.mjs.map +0 -1
  86. package/dist/esm/ToastImperative.native.js +0 -82
  87. package/dist/esm/ToastImperative.native.js.map +0 -1
  88. package/dist/esm/ToastImpl.mjs +0 -256
  89. package/dist/esm/ToastImpl.mjs.map +0 -1
  90. package/dist/esm/ToastImpl.native.js +0 -287
  91. package/dist/esm/ToastImpl.native.js.map +0 -1
  92. package/dist/esm/ToastPortal.mjs +0 -21
  93. package/dist/esm/ToastPortal.mjs.map +0 -1
  94. package/dist/esm/ToastPortal.native.js +0 -21
  95. package/dist/esm/ToastPortal.native.js.map +0 -1
  96. package/dist/esm/ToastProvider.mjs +0 -108
  97. package/dist/esm/ToastProvider.mjs.map +0 -1
  98. package/dist/esm/ToastProvider.native.js +0 -118
  99. package/dist/esm/ToastProvider.native.js.map +0 -1
  100. package/dist/esm/ToastViewport.mjs +0 -241
  101. package/dist/esm/ToastViewport.mjs.map +0 -1
  102. package/dist/esm/ToastViewport.native.js +0 -275
  103. package/dist/esm/ToastViewport.native.js.map +0 -1
  104. package/dist/esm/constants.mjs +0 -4
  105. package/dist/esm/constants.mjs.map +0 -1
  106. package/dist/esm/constants.native.js +0 -4
  107. package/dist/esm/constants.native.js.map +0 -1
  108. package/dist/esm/createNativeToast.mjs +0 -27
  109. package/dist/esm/createNativeToast.mjs.map +0 -1
  110. package/dist/esm/createNativeToast.native.js +0 -21
  111. package/dist/esm/createNativeToast.native.js.map +0 -1
  112. package/dist/esm/index.js +0 -2
  113. package/dist/esm/index.js.map +0 -1
  114. package/dist/esm/index.mjs +0 -2
  115. package/dist/esm/index.mjs.map +0 -1
  116. package/dist/esm/index.native.js +0 -2
  117. package/dist/esm/index.native.js.map +0 -1
  118. package/dist/esm/types.mjs +0 -2
  119. package/dist/esm/types.mjs.map +0 -1
  120. package/dist/esm/types.native.js +0 -2
  121. package/dist/esm/types.native.js.map +0 -1
  122. package/dist/jsx/Toast.mjs +0 -131
  123. package/dist/jsx/Toast.mjs.map +0 -1
  124. package/dist/jsx/Toast.native.js +0 -174
  125. package/dist/jsx/Toast.native.js.map +0 -1
  126. package/dist/jsx/ToastAnnounce.mjs +0 -62
  127. package/dist/jsx/ToastAnnounce.mjs.map +0 -1
  128. package/dist/jsx/ToastAnnounce.native.js +0 -105
  129. package/dist/jsx/ToastAnnounce.native.js.map +0 -1
  130. package/dist/jsx/ToastImperative.mjs +0 -63
  131. package/dist/jsx/ToastImperative.mjs.map +0 -1
  132. package/dist/jsx/ToastImperative.native.js +0 -122
  133. package/dist/jsx/ToastImperative.native.js.map +0 -1
  134. package/dist/jsx/ToastImpl.mjs +0 -256
  135. package/dist/jsx/ToastImpl.mjs.map +0 -1
  136. package/dist/jsx/ToastImpl.native.js +0 -326
  137. package/dist/jsx/ToastImpl.native.js.map +0 -1
  138. package/dist/jsx/ToastPortal.mjs +0 -21
  139. package/dist/jsx/ToastPortal.mjs.map +0 -1
  140. package/dist/jsx/ToastPortal.native.js +0 -47
  141. package/dist/jsx/ToastPortal.native.js.map +0 -1
  142. package/dist/jsx/ToastProvider.mjs +0 -108
  143. package/dist/jsx/ToastProvider.mjs.map +0 -1
  144. package/dist/jsx/ToastProvider.native.js +0 -159
  145. package/dist/jsx/ToastProvider.native.js.map +0 -1
  146. package/dist/jsx/ToastViewport.mjs +0 -241
  147. package/dist/jsx/ToastViewport.mjs.map +0 -1
  148. package/dist/jsx/ToastViewport.native.js +0 -315
  149. package/dist/jsx/ToastViewport.native.js.map +0 -1
  150. package/dist/jsx/constants.mjs +0 -4
  151. package/dist/jsx/constants.mjs.map +0 -1
  152. package/dist/jsx/constants.native.js +0 -31
  153. package/dist/jsx/constants.native.js.map +0 -1
  154. package/dist/jsx/createNativeToast.mjs +0 -27
  155. package/dist/jsx/createNativeToast.mjs.map +0 -1
  156. package/dist/jsx/createNativeToast.native.js +0 -48
  157. package/dist/jsx/createNativeToast.native.js.map +0 -1
  158. package/dist/jsx/index.js +0 -2
  159. package/dist/jsx/index.js.map +0 -1
  160. package/dist/jsx/index.mjs +0 -2
  161. package/dist/jsx/index.mjs.map +0 -1
  162. package/dist/jsx/index.native.js +0 -21
  163. package/dist/jsx/index.native.js.map +0 -1
  164. package/dist/jsx/types.mjs +0 -2
  165. package/dist/jsx/types.mjs.map +0 -1
  166. package/dist/jsx/types.native.js +0 -19
  167. package/dist/jsx/types.native.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { AnimatePresence } from '@hanzogui/animate-presence'
2
2
  import { useComposedRefs } from '@hanzogui/compose-refs'
3
3
  import { isWeb } from '@hanzogui/constants'
4
- import type { GetProps, GuiElement } from '@hanzogui/core'
4
+ import type { GetProps, HanzoguiElement } from '@hanzogui/core'
5
5
  import { styled } from '@hanzogui/core'
6
6
  import { PortalHost } from '@hanzogui/portal'
7
7
  import { YStack } from '@hanzogui/stacks'
@@ -28,7 +28,7 @@ const ToastViewportWrapperFrame = styled(YStack, {
28
28
  bottom: 0,
29
29
  left: 0,
30
30
  right: 0,
31
- position: 'fixed',
31
+ position: isWeb ? ('fixed' as any) : 'absolute',
32
32
  maxWidth: '100%',
33
33
  tabIndex: 0,
34
34
  zIndex: 100000,
@@ -37,7 +37,7 @@ const ToastViewportWrapperFrame = styled(YStack, {
37
37
  } as const,
38
38
 
39
39
  defaultVariants: {
40
- unstyled: process.env.HANZO_GUI_HEADLESS === '1',
40
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
41
41
  },
42
42
  })
43
43
 
@@ -48,14 +48,14 @@ const ToastViewportFrame = styled(YStack, {
48
48
  unstyled: {
49
49
  false: {
50
50
  pointerEvents: 'box-none',
51
- position: 'fixed',
51
+ position: isWeb ? ('fixed' as any) : 'absolute',
52
52
  maxWidth: '100%',
53
53
  },
54
54
  },
55
55
  } as const,
56
56
 
57
57
  defaultVariants: {
58
- unstyled: process.env.HANZO_GUI_HEADLESS === '1',
58
+ unstyled: process.env.HANZOGUI_HEADLESS === '1',
59
59
  },
60
60
  })
61
61
 
@@ -81,7 +81,7 @@ type ToastViewportProps = ToastViewportFrameProps & {
81
81
  */
82
82
  multipleToasts?: boolean
83
83
  /**
84
- * When true, uses a portal to render at the very top of the root GuiProvider.
84
+ * When true, uses a portal to render at the very top of the root HanzoguiProvider.
85
85
  */
86
86
  portalToRoot?: boolean
87
87
  }
@@ -106,7 +106,7 @@ const ToastViewport = React.memo(
106
106
  const wrapperRef = React.useRef<HTMLDivElement>(null)
107
107
  const ref = React.useRef<HTMLDivElement>(null)
108
108
  const onViewportChange = React.useCallback(
109
- (el: GuiElement) => {
109
+ (el: HanzoguiElement) => {
110
110
  if (context.viewports[name] !== el) context.onViewportChange(name, el)
111
111
  },
112
112
  [name, context.viewports]
@@ -262,6 +262,7 @@ const ToastViewport = React.memo(
262
262
  const contents = (
263
263
  <ToastViewportWrapperFrame
264
264
  ref={wrapperRef as any}
265
+ // biome-ignore lint/a11y/useSemanticElements: <explanation>
265
266
  role="region"
266
267
  aria-label={label.replace('{hotkey}', hotkeyLabel)}
267
268
  // // Ensure virtual cursor from landmarks menus triggers focus/blur for pause/resume
@@ -363,7 +364,7 @@ const FocusProxy = React.forwardRef<FocusProxyElement, ScopedProps<FocusProxyPro
363
364
  {...proxyProps}
364
365
  ref={forwardedRef}
365
366
  // Avoid page scrolling when focus is on the focus proxy
366
- position="fixed"
367
+ position={isWeb ? ('fixed' as any) : 'absolute'}
367
368
  onFocus={(event) => {
368
369
  if (!isWeb) return
369
370
  const prevFocusedElement = event.relatedTarget as HTMLElement | null
@@ -379,7 +380,7 @@ FocusProxy.displayName = FOCUS_PROXY_NAME
379
380
 
380
381
  /* -----------------------------------------------------------------------------------------------*/
381
382
 
382
- function focusFirst(candidates: GuiElement[]) {
383
+ function focusFirst(candidates: HanzoguiElement[]) {
383
384
  if (!isWeb) return
384
385
  const previouslyFocusedElement = document.activeElement
385
386
  return candidates.some((candidate) => {
@@ -400,7 +401,7 @@ function focusFirst(candidates: GuiElement[]) {
400
401
  * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
401
402
  * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
402
403
  */
403
- function getTabbableCandidates(container: GuiElement) {
404
+ function getTabbableCandidates(container: HanzoguiElement) {
404
405
  if (!isWeb) return []
405
406
  const containerHtml = container as HTMLElement
406
407
  const nodes: HTMLElement[] = []
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Toaster — drop-in all-in-one toast component.
3
+ * Thin wrapper around Toast + Toast.Viewport + Toast.List (composable API).
4
+ */
5
+
6
+ import type { HanzoguiElement } from '@hanzogui/core'
7
+ import * as React from 'react'
8
+ import { Toast } from './ToastComposable'
9
+ import type { ToastPosition } from './ToastComposable'
10
+ import type { SwipeDirection } from './ToastProvider'
11
+ import type { ExternalToast } from './ToastState'
12
+ import type { BurntToastOptions } from './types'
13
+
14
+ export type ToasterPosition = ToastPosition
15
+
16
+ export interface ToasterProps {
17
+ /**
18
+ * Position of the toasts on screen
19
+ * @default 'bottom-right'
20
+ */
21
+ position?: ToasterPosition
22
+
23
+ /**
24
+ * Expand toasts on hover to show all
25
+ * @default false
26
+ */
27
+ expand?: boolean
28
+
29
+ /**
30
+ * Number of toasts visible at once
31
+ * @default 4
32
+ */
33
+ visibleToasts?: number
34
+
35
+ /**
36
+ * Gap between toasts in pixels
37
+ * @default 14
38
+ */
39
+ gap?: number
40
+
41
+ /**
42
+ * Default duration for toasts in ms
43
+ * @default 4000
44
+ */
45
+ duration?: number
46
+
47
+ /**
48
+ * Offset from screen edge in pixels
49
+ * @default 24
50
+ */
51
+ offset?: number | { top?: number; right?: number; bottom?: number; left?: number }
52
+
53
+ /**
54
+ * Hotkey to focus toast viewport
55
+ * @default ['altKey', 'KeyT']
56
+ */
57
+ hotkey?: string[]
58
+
59
+ /**
60
+ * Direction(s) toasts can be swiped to dismiss.
61
+ * 'auto' detects based on position (swipe toward nearest edge).
62
+ * @default 'auto'
63
+ */
64
+ swipeDirection?: SwipeDirection
65
+
66
+ /**
67
+ * Distance in pixels swipe must pass to dismiss
68
+ * @default 50
69
+ */
70
+ swipeThreshold?: number
71
+
72
+ /**
73
+ * Show close button on toasts
74
+ * @default false
75
+ */
76
+ closeButton?: boolean
77
+
78
+ /**
79
+ * Theme for toasts (auto-detected if not set)
80
+ */
81
+ theme?: 'light' | 'dark' | 'system'
82
+
83
+ /**
84
+ * Custom icons for toast types
85
+ */
86
+ icons?: {
87
+ success?: React.ReactNode
88
+ error?: React.ReactNode
89
+ warning?: React.ReactNode
90
+ info?: React.ReactNode
91
+ loading?: React.ReactNode
92
+ close?: React.ReactNode
93
+ }
94
+
95
+ /**
96
+ * Default toast options
97
+ */
98
+ toastOptions?: ExternalToast
99
+
100
+ /**
101
+ * Container aria label for screen readers
102
+ * @default 'Notifications'
103
+ */
104
+ containerAriaLabel?: string
105
+
106
+ /**
107
+ * When true, uses burnt native OS toasts on mobile instead of RN views.
108
+ * @default false
109
+ */
110
+ native?: boolean
111
+
112
+ /**
113
+ * Options for burnt native toasts on mobile
114
+ */
115
+ burntOptions?: Omit<BurntToastOptions, 'title' | 'message' | 'duration'>
116
+
117
+ /**
118
+ * Options for web Notification API
119
+ */
120
+ notificationOptions?: NotificationOptions
121
+
122
+ /**
123
+ * Force reduced motion mode (disables animations)
124
+ */
125
+ reducedMotion?: boolean
126
+ }
127
+
128
+ export const Toaster = React.forwardRef<HanzoguiElement, ToasterProps>(
129
+ function Toaster(props, ref) {
130
+ const {
131
+ position = 'bottom-right',
132
+ expand = false,
133
+ visibleToasts,
134
+ gap,
135
+ duration,
136
+ offset,
137
+ hotkey,
138
+ swipeDirection,
139
+ swipeThreshold,
140
+ closeButton,
141
+ theme,
142
+ icons,
143
+ toastOptions,
144
+ containerAriaLabel = 'Notifications',
145
+ native,
146
+ burntOptions,
147
+ notificationOptions,
148
+ reducedMotion,
149
+ } = props
150
+
151
+ return (
152
+ <Toast
153
+ position={position}
154
+ expand={expand}
155
+ visibleToasts={visibleToasts}
156
+ gap={gap}
157
+ duration={toastOptions?.duration ?? duration}
158
+ swipeDirection={swipeDirection}
159
+ swipeThreshold={swipeThreshold}
160
+ closeButton={closeButton}
161
+ theme={theme}
162
+ icons={icons}
163
+ native={native}
164
+ burntOptions={burntOptions}
165
+ notificationOptions={notificationOptions}
166
+ reducedMotion={reducedMotion}
167
+ >
168
+ <Toast.Viewport
169
+ ref={ref}
170
+ offset={offset}
171
+ hotkey={hotkey}
172
+ label={containerAriaLabel}
173
+ >
174
+ <Toast.List />
175
+ </Toast.Viewport>
176
+ </Toast>
177
+ )
178
+ }
179
+ )
180
+
181
+ Toaster.displayName = 'Toaster'
@@ -27,3 +27,7 @@ export const hideNativeToast: HideNativeToastsFn = () => {
27
27
  if (!burnt.isEnabled) return
28
28
  burnt.state.dismissAllAlerts!()
29
29
  }
30
+
31
+ export async function requestNotificationPermission(): Promise<null> {
32
+ return null
33
+ }
@@ -5,40 +5,23 @@ export const createNativeToast: CreateNativeToastsFn = (
5
5
  { message, notificationOptions }
6
6
  ) => {
7
7
  if (!('Notification' in window)) {
8
- console.error('This browser does not support notifications')
9
8
  return false
10
9
  }
11
10
 
12
- if (Notification.permission === 'denied') return false
13
- const showNotification = () => {
14
- const notification = new Notification(title, {
15
- body: message,
16
- ...notificationOptions,
17
- })
11
+ if (Notification.permission !== 'granted') return false
18
12
 
19
- return notification
20
- }
13
+ const notification = new Notification(title, {
14
+ body: message,
15
+ ...notificationOptions,
16
+ })
21
17
 
22
- if (Notification.permission === 'granted') {
23
- const notification = showNotification()
24
- return {
25
- nativeToastRef: notification,
26
- }
18
+ return {
19
+ nativeToastRef: notification,
27
20
  }
28
- Notification.requestPermission().then((permission) => {
29
- if (permission === 'granted') {
30
- const notification = showNotification()
31
- return {
32
- nativeToastRef: notification,
33
- }
34
- }
35
- })
36
- return true
37
21
  }
38
22
 
39
23
  export const hideNativeToast: HideNativeToastsFn = (ref) => {
40
24
  if (!('Notification' in window)) {
41
- console.error('This browser does not support notifications')
42
25
  return
43
26
  }
44
27
 
@@ -46,3 +29,14 @@ export const hideNativeToast: HideNativeToastsFn = (ref) => {
46
29
  ref.close()
47
30
  }
48
31
  }
32
+
33
+ /**
34
+ * Request notification permission from the browser.
35
+ * Must be called from a user gesture (click/tap handler).
36
+ */
37
+ export async function requestNotificationPermission(): Promise<NotificationPermission | null> {
38
+ if (!('Notification' in window)) return null
39
+ if (Notification.permission === 'granted') return 'granted'
40
+ if (Notification.permission === 'denied') return 'denied'
41
+ return Notification.requestPermission()
42
+ }
@@ -0,0 +1,43 @@
1
+ import type { ToastT } from './ToastState'
2
+ import type { BurntToastOptions } from './types'
3
+ import { createNativeToast } from './createNativeToast'
4
+
5
+ /**
6
+ * Attempts to dispatch a toast via native platform API (Burnt on mobile, Notification on web).
7
+ * Returns true if the toast was handled natively, false if it should fall through to in-app.
8
+ */
9
+ export function dispatchNativeToast(
10
+ toast: ToastT,
11
+ opts: {
12
+ duration: number
13
+ burntOptions?: Omit<BurntToastOptions, 'title' | 'message' | 'duration'>
14
+ notificationOptions?: NotificationOptions
15
+ }
16
+ ): boolean {
17
+ const titleText = typeof toast.title === 'function' ? toast.title() : toast.title
18
+ if (typeof titleText !== 'string') return false
19
+
20
+ const descText =
21
+ typeof toast.description === 'function' ? toast.description() : toast.description
22
+ const toastType = toast.type ?? 'default'
23
+
24
+ const preset: BurntToastOptions['preset'] =
25
+ toastType === 'error' ? 'error' : toastType === 'success' ? 'done' : 'none'
26
+ const haptic: BurntToastOptions['haptic'] =
27
+ toastType === 'error'
28
+ ? 'error'
29
+ : toastType === 'success'
30
+ ? 'success'
31
+ : toastType === 'warning'
32
+ ? 'warning'
33
+ : 'none'
34
+
35
+ const result = createNativeToast(titleText, {
36
+ message: typeof descText === 'string' ? descText : undefined,
37
+ duration: toast.duration ?? opts.duration,
38
+ burntOptions: { preset, haptic, ...opts.burntOptions },
39
+ notificationOptions: opts.notificationOptions,
40
+ })
41
+
42
+ return result !== false
43
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Native drag gesture handling for toast swipe-to-dismiss.
3
+ *
4
+ * Uses react-native-gesture-handler (RNGH) when available for proper gesture
5
+ * coordination with ScrollView and navigation. Falls back to PanResponder.
6
+ *
7
+ * Pattern: same as Sheet — RNGH is accessed through @hanzogui/native global
8
+ * registry, never imported directly. The gesture is created in useMemo and
9
+ * returns null when RNGH is not set up.
10
+ */
11
+
12
+ import { getGestureHandler } from '@hanzogui/native'
13
+ import * as React from 'react'
14
+ import type { PanResponderGestureState } from 'react-native'
15
+ import { PanResponder } from 'react-native'
16
+ import type { SwipeDirection } from './ToastProvider'
17
+
18
+ export interface UseAnimatedDragGestureOptions {
19
+ direction: SwipeDirection
20
+ threshold: number
21
+ disabled?: boolean
22
+ expanded?: boolean
23
+ onDragMove: (x: number, y: number) => void
24
+ onDragStart?: () => void
25
+ onDismiss: (exitDirection: 'left' | 'right' | 'up' | 'down', velocity: number) => void
26
+ onCancel: () => void
27
+ }
28
+
29
+ const VELOCITY_THRESHOLD = 0.11
30
+ const GESTURE_GRANT_THRESHOLD = 10
31
+
32
+ function resisted(delta: number, maxResist = 25): number {
33
+ if (delta >= 0) return delta
34
+ const pastBoundary = Math.abs(delta)
35
+ const resistedDistance = Math.sqrt(pastBoundary) * 2
36
+ return -Math.min(resistedDistance, maxResist)
37
+ }
38
+
39
+ const EXIT_DRAG_CAP = 80
40
+
41
+ function cappedExit(delta: number): number {
42
+ if (Math.abs(delta) <= EXIT_DRAG_CAP) return delta
43
+ const sign = delta > 0 ? 1 : -1
44
+ const overshoot = Math.abs(delta) - EXIT_DRAG_CAP
45
+ return sign * (EXIT_DRAG_CAP + Math.sqrt(overshoot) * 2)
46
+ }
47
+
48
+ function computeOffset(
49
+ direction: SwipeDirection,
50
+ dx: number,
51
+ dy: number
52
+ ): { offsetX: number; offsetY: number } {
53
+ let offsetX = 0
54
+ let offsetY = 0
55
+
56
+ if (direction === 'right') {
57
+ offsetX = dx > 0 ? cappedExit(dx) : resisted(dx)
58
+ } else if (direction === 'left') {
59
+ offsetX = dx < 0 ? cappedExit(dx) : -resisted(-dx)
60
+ } else if (direction === 'down') {
61
+ offsetY = dy > 0 ? cappedExit(dy) : resisted(dy)
62
+ } else if (direction === 'up') {
63
+ offsetY = dy < 0 ? cappedExit(dy) : -resisted(-dy)
64
+ } else if (direction === 'horizontal') {
65
+ offsetX = cappedExit(dx)
66
+ } else if (direction === 'vertical') {
67
+ offsetY = cappedExit(dy)
68
+ }
69
+
70
+ return { offsetX, offsetY }
71
+ }
72
+
73
+ function computeExitDirection(
74
+ direction: SwipeDirection,
75
+ dx: number,
76
+ dy: number
77
+ ): 'left' | 'right' | 'up' | 'down' | null {
78
+ if (direction === 'right' && dx > 0) return 'right'
79
+ if (direction === 'left' && dx < 0) return 'left'
80
+ if (direction === 'horizontal') {
81
+ if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left'
82
+ }
83
+ if (direction === 'down' && dy > 0) return 'down'
84
+ if (direction === 'up' && dy < 0) return 'up'
85
+ if (direction === 'vertical') {
86
+ if (Math.abs(dy) > Math.abs(dx)) return dy > 0 ? 'down' : 'up'
87
+ }
88
+ return null
89
+ }
90
+
91
+ function shouldGrantGestureMove(dir: SwipeDirection, dx: number, dy: number): boolean {
92
+ const absDx = Math.abs(dx)
93
+ const absDy = Math.abs(dy)
94
+ if (
95
+ (dir === 'horizontal' || dir === 'left' || dir === 'right') &&
96
+ absDx > GESTURE_GRANT_THRESHOLD &&
97
+ absDx > absDy
98
+ ) {
99
+ return true
100
+ }
101
+ if (
102
+ (dir === 'vertical' || dir === 'up' || dir === 'down') &&
103
+ absDy > GESTURE_GRANT_THRESHOLD &&
104
+ absDy > absDx
105
+ ) {
106
+ return true
107
+ }
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * Single hook — always calls the same hooks in the same order.
113
+ * Creates RNGH gesture in useMemo (returns null if unavailable).
114
+ * Creates PanResponder in useMemo (returns null if RNGH is used).
115
+ * Consumer checks `gesture` to decide whether to wrap with GestureDetector.
116
+ */
117
+ export function useAnimatedDragGesture(options: UseAnimatedDragGestureOptions) {
118
+ const { direction, threshold, disabled } = options
119
+
120
+ const [isDragging, setIsDragging] = React.useState(false)
121
+
122
+ const isHorizontal =
123
+ direction === 'left' || direction === 'right' || direction === 'horizontal'
124
+
125
+ const gestureRef = React.useRef<any>(null)
126
+
127
+ // store callbacks in refs for stable closures
128
+ const onDragMoveRef = React.useRef(options.onDragMove)
129
+ const onDragStartRef = React.useRef(options.onDragStart)
130
+ const onDismissRef = React.useRef(options.onDismiss)
131
+ const onCancelRef = React.useRef(options.onCancel)
132
+ onDragMoveRef.current = options.onDragMove
133
+ onDragStartRef.current = options.onDragStart
134
+ onDismissRef.current = options.onDismiss
135
+ onCancelRef.current = options.onCancel
136
+
137
+ // check once — RNGH availability is set at app init and never changes
138
+ const rnghEnabled = getGestureHandler().isEnabled
139
+
140
+ // RNGH gesture (null if not available)
141
+ const gesture = React.useMemo(() => {
142
+ if (!rnghEnabled || disabled) return null
143
+
144
+ const { Gesture } = getGestureHandler().state
145
+ if (!Gesture) return null
146
+
147
+ const pan = Gesture.Pan()
148
+ .withRef(gestureRef)
149
+ .shouldCancelWhenOutside(false)
150
+ .runOnJS(true)
151
+
152
+ if (isHorizontal) {
153
+ pan.activeOffsetX([-10, 10])
154
+ pan.failOffsetY([-20, 20])
155
+ } else {
156
+ pan.activeOffsetY([-10, 10])
157
+ pan.failOffsetX([-20, 20])
158
+ }
159
+
160
+ pan
161
+ .onStart(() => {
162
+ setIsDragging(true)
163
+ onDragStartRef.current?.()
164
+ })
165
+ .onChange((event: any) => {
166
+ const { offsetX, offsetY } = computeOffset(
167
+ direction,
168
+ event.translationX,
169
+ event.translationY
170
+ )
171
+ onDragMoveRef.current(offsetX, offsetY)
172
+ })
173
+ .onEnd((event: any) => {
174
+ const dx = event.translationX
175
+ const dy = event.translationY
176
+ const relevantDelta = isHorizontal ? dx : dy
177
+ const relevantVelocity = isHorizontal
178
+ ? Math.abs(event.velocityX / 1000)
179
+ : Math.abs(event.velocityY / 1000)
180
+
181
+ const passedThreshold = Math.abs(relevantDelta) >= threshold
182
+ const hasVelocity = relevantVelocity > VELOCITY_THRESHOLD
183
+ const exitDirection = computeExitDirection(direction, dx, dy)
184
+ const shouldDismiss = exitDirection && (passedThreshold || hasVelocity)
185
+
186
+ setIsDragging(false)
187
+
188
+ if (shouldDismiss && exitDirection) {
189
+ onDismissRef.current(exitDirection, relevantVelocity)
190
+ } else {
191
+ onCancelRef.current()
192
+ }
193
+ })
194
+ .onFinalize(() => {
195
+ setIsDragging(false)
196
+ })
197
+
198
+ return pan
199
+ }, [disabled, direction, threshold, isHorizontal, rnghEnabled])
200
+
201
+ // PanResponder fallback (null if RNGH is used)
202
+ const panResponder = React.useMemo(() => {
203
+ if (rnghEnabled || disabled) return null
204
+
205
+ return PanResponder.create({
206
+ onMoveShouldSetPanResponder: (_e, g) => {
207
+ return shouldGrantGestureMove(direction, g.dx, g.dy)
208
+ },
209
+ onMoveShouldSetPanResponderCapture: (_e, g) => {
210
+ return shouldGrantGestureMove(direction, g.dx, g.dy)
211
+ },
212
+ onPanResponderTerminationRequest: () => false,
213
+
214
+ onPanResponderGrant: () => {
215
+ setIsDragging(true)
216
+ onDragStartRef.current?.()
217
+ },
218
+
219
+ onPanResponderMove: (_e, g: PanResponderGestureState) => {
220
+ const { offsetX, offsetY } = computeOffset(direction, g.dx, g.dy)
221
+ onDragMoveRef.current(offsetX, offsetY)
222
+ },
223
+
224
+ onPanResponderRelease: (_e, g: PanResponderGestureState) => {
225
+ const { dx, dy, vx, vy } = g
226
+ const relevantDelta = isHorizontal ? dx : dy
227
+ const relevantVelocity = isHorizontal ? Math.abs(vx) : Math.abs(vy)
228
+
229
+ const passedThreshold = Math.abs(relevantDelta) >= threshold
230
+ const hasVelocity = relevantVelocity > VELOCITY_THRESHOLD
231
+ const exitDirection = computeExitDirection(direction, dx, dy)
232
+ const shouldDismiss = exitDirection && (passedThreshold || hasVelocity)
233
+
234
+ setIsDragging(false)
235
+
236
+ if (shouldDismiss && exitDirection) {
237
+ onDismissRef.current(exitDirection, relevantVelocity)
238
+ } else {
239
+ onCancelRef.current()
240
+ }
241
+ },
242
+
243
+ onPanResponderTerminate: () => {
244
+ setIsDragging(false)
245
+ onCancelRef.current()
246
+ },
247
+ })
248
+ }, [disabled, direction, threshold, isHorizontal, rnghEnabled])
249
+
250
+ return {
251
+ isDragging,
252
+ gestureHandlers: panResponder?.panHandlers ?? {},
253
+ gesture,
254
+ }
255
+ }