@hanzogui/toast 3.0.1 → 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
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Web implementation of drag gesture handling with animation driver integration.
3
+ * Uses pointer events for smooth drag tracking, animation driver for transforms.
4
+ */
5
+
6
+ import * as React from 'react'
7
+ import type { SwipeDirection } from './ToastProvider'
8
+
9
+ export interface UseAnimatedDragGestureOptions {
10
+ direction: SwipeDirection
11
+ threshold: number
12
+ disabled?: boolean
13
+ /** when collapsed, allow drag in all directions with resistance except exit direction */
14
+ expanded?: boolean
15
+ /** called during drag with offset values */
16
+ onDragMove: (x: number, y: number) => void
17
+ /** called when drag starts */
18
+ onDragStart?: () => void
19
+ /** called when drag ends with successful dismiss - includes exit direction and velocity */
20
+ onDismiss: (exitDirection: 'left' | 'right' | 'up' | 'down', velocity: number) => void
21
+ /** called when drag ends without dismiss - spring back */
22
+ onCancel: () => void
23
+ }
24
+
25
+ interface DragStartData {
26
+ startX: number
27
+ startY: number
28
+ startTime: number
29
+ pointerId: number
30
+ }
31
+
32
+ const VELOCITY_THRESHOLD = 0.11
33
+
34
+ /**
35
+ * Apply resistance when dragging past a boundary.
36
+ * Uses a square root curve for natural-feeling resistance (same as Sheet).
37
+ */
38
+ function resisted(delta: number, maxResist = 25): number {
39
+ if (delta >= 0) return delta
40
+ const pastBoundary = Math.abs(delta)
41
+ const resistedDistance = Math.sqrt(pastBoundary) * 2
42
+ return -Math.min(resistedDistance, maxResist)
43
+ }
44
+
45
+ /**
46
+ * Cap the exit-direction drag distance so the toast doesn't fly across the screen.
47
+ * Allows free movement up to the limit, then applies gentle resistance beyond it.
48
+ */
49
+ const EXIT_DRAG_CAP = 80
50
+
51
+ function cappedExit(delta: number): number {
52
+ if (Math.abs(delta) <= EXIT_DRAG_CAP) return delta
53
+ const sign = delta > 0 ? 1 : -1
54
+ const overshoot = Math.abs(delta) - EXIT_DRAG_CAP
55
+ return sign * (EXIT_DRAG_CAP + Math.sqrt(overshoot) * 2)
56
+ }
57
+
58
+ export function useAnimatedDragGesture(options: UseAnimatedDragGestureOptions) {
59
+ const {
60
+ direction,
61
+ threshold,
62
+ disabled,
63
+ expanded,
64
+ onDragMove,
65
+ onDragStart,
66
+ onDismiss,
67
+ onCancel,
68
+ } = options
69
+
70
+ const [isDragging, setIsDragging] = React.useState(false)
71
+
72
+ const dragStartRef = React.useRef<DragStartData | null>(null)
73
+ const lockedDirectionRef = React.useRef<'x' | 'y' | null>(null)
74
+ // Stable ref for pointer capture - avoids issues with event.target changing
75
+ const captureElementRef = React.useRef<HTMLElement | null>(null)
76
+
77
+ const isHorizontal =
78
+ direction === 'left' || direction === 'right' || direction === 'horizontal'
79
+ const isVertical =
80
+ direction === 'up' || direction === 'down' || direction === 'vertical'
81
+
82
+ // block native text selection during drag — works in Safari where
83
+ // userSelect style alone doesn't prevent fast-swipe selection
84
+ const preventSelectRef = React.useRef<((e: Event) => void) | null>(null)
85
+
86
+ function startPreventingSelection() {
87
+ if (typeof document === 'undefined') return
88
+ // clear any selection that already started
89
+ window.getSelection()?.removeAllRanges()
90
+ // block future selection attempts at the event level
91
+ const handler = (e: Event) => e.preventDefault()
92
+ preventSelectRef.current = handler
93
+ document.addEventListener('selectstart', handler, true)
94
+ }
95
+
96
+ function stopPreventingSelection() {
97
+ if (preventSelectRef.current) {
98
+ document.removeEventListener('selectstart', preventSelectRef.current, true)
99
+ preventSelectRef.current = null
100
+ }
101
+ }
102
+
103
+ // Cleanup function to reset all drag state
104
+ const cleanup = React.useCallback(() => {
105
+ dragStartRef.current = null
106
+ lockedDirectionRef.current = null
107
+ setIsDragging(false)
108
+ stopPreventingSelection()
109
+ }, [])
110
+
111
+ // Defensive cleanup on unmount - if toast unmounts while dragging
112
+ React.useEffect(() => {
113
+ return () => {
114
+ if (dragStartRef.current) {
115
+ // Release pointer capture if we have it
116
+ if (captureElementRef.current && dragStartRef.current.pointerId) {
117
+ try {
118
+ captureElementRef.current.releasePointerCapture(
119
+ dragStartRef.current.pointerId
120
+ )
121
+ } catch {
122
+ // ignore
123
+ }
124
+ }
125
+ cleanup()
126
+ // Note: can't call onCancel here as component is unmounting
127
+ }
128
+ }
129
+ }, [cleanup])
130
+
131
+ const handlePointerDown = React.useCallback(
132
+ (event: React.PointerEvent) => {
133
+ if (disabled) return
134
+ if (event.button !== 0) return
135
+
136
+ // don't start drag if clicking on interactive elements (buttons, links, inputs)
137
+ const target = event.target as HTMLElement
138
+ if (target.closest('button, a, input, textarea, select, [role="button"]')) {
139
+ return
140
+ }
141
+
142
+ // don't start drag if user has text selected (let them copy it)
143
+ const hasSelection = (window.getSelection()?.toString().length ?? 0) > 0
144
+ if (hasSelection) return
145
+
146
+ // Use currentTarget (the element with the handler) for stable capture
147
+ const captureElement = event.currentTarget as HTMLElement
148
+ captureElementRef.current = captureElement
149
+ captureElement.setPointerCapture(event.pointerId)
150
+
151
+ dragStartRef.current = {
152
+ startX: event.clientX,
153
+ startY: event.clientY,
154
+ startTime: Date.now(),
155
+ pointerId: event.pointerId,
156
+ }
157
+
158
+ // prevent text selection on the page during drag
159
+ startPreventingSelection()
160
+
161
+ setIsDragging(true)
162
+ onDragStart?.()
163
+ },
164
+ [disabled, onDragStart]
165
+ )
166
+
167
+ const handlePointerMove = React.useCallback(
168
+ (event: React.PointerEvent) => {
169
+ if (!dragStartRef.current || disabled) return
170
+
171
+ const deltaX = event.clientX - dragStartRef.current.startX
172
+ const deltaY = event.clientY - dragStartRef.current.startY
173
+
174
+ // detect direction lock on first significant movement
175
+ if (!lockedDirectionRef.current && (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)) {
176
+ lockedDirectionRef.current = Math.abs(deltaX) > Math.abs(deltaY) ? 'x' : 'y'
177
+ }
178
+
179
+ let offsetX = 0
180
+ let offsetY = 0
181
+
182
+ // only allow movement along the exit axis with capped distance
183
+ // cross-axis is locked to zero for clean swipes
184
+ if (direction === 'right') {
185
+ offsetX = deltaX > 0 ? cappedExit(deltaX) : resisted(deltaX)
186
+ } else if (direction === 'left') {
187
+ offsetX = deltaX < 0 ? cappedExit(deltaX) : -resisted(-deltaX)
188
+ } else if (direction === 'down') {
189
+ offsetY = deltaY > 0 ? cappedExit(deltaY) : resisted(deltaY)
190
+ } else if (direction === 'up') {
191
+ offsetY = deltaY < 0 ? cappedExit(deltaY) : -resisted(-deltaY)
192
+ } else if (direction === 'horizontal') {
193
+ offsetX = cappedExit(deltaX)
194
+ } else if (direction === 'vertical') {
195
+ offsetY = cappedExit(deltaY)
196
+ }
197
+
198
+ // directly update animated values (no React state update during drag)
199
+ onDragMove(offsetX, offsetY)
200
+ },
201
+ [disabled, direction, expanded, isHorizontal, isVertical, onDragMove]
202
+ )
203
+
204
+ const handlePointerUp = React.useCallback(
205
+ (event: React.PointerEvent) => {
206
+ if (!dragStartRef.current || disabled) return
207
+
208
+ const deltaX = event.clientX - dragStartRef.current.startX
209
+ const deltaY = event.clientY - dragStartRef.current.startY
210
+ const timeTaken = Date.now() - dragStartRef.current.startTime
211
+
212
+ const velocityX = Math.abs(deltaX) / timeTaken
213
+ const velocityY = Math.abs(deltaY) / timeTaken
214
+
215
+ const lockedDirection = lockedDirectionRef.current
216
+
217
+ // if locked to wrong axis for the swipe direction, don't dismiss
218
+ // e.g., if drag started vertical but swipe direction is horizontal
219
+ const isLockedToWrongAxis =
220
+ (lockedDirection === 'y' && isHorizontal) ||
221
+ (lockedDirection === 'x' && isVertical)
222
+
223
+ const relevantDelta = isHorizontal ? deltaX : deltaY
224
+ const relevantVelocity = isHorizontal ? velocityX : velocityY
225
+
226
+ const passedThreshold = Math.abs(relevantDelta) >= threshold
227
+ const hasVelocity = relevantVelocity > VELOCITY_THRESHOLD
228
+
229
+ // determine exit direction based on actual drag direction
230
+ let exitDirection: 'left' | 'right' | 'up' | 'down' | null = null
231
+
232
+ // only set exit direction if not locked to wrong axis
233
+ if (!isLockedToWrongAxis) {
234
+ if (direction === 'right' && deltaX > 0) exitDirection = 'right'
235
+ else if (direction === 'left' && deltaX < 0) exitDirection = 'left'
236
+ else if (direction === 'horizontal') {
237
+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
238
+ exitDirection = deltaX > 0 ? 'right' : 'left'
239
+ }
240
+ } else if (direction === 'down' && deltaY > 0) exitDirection = 'down'
241
+ else if (direction === 'up' && deltaY < 0) exitDirection = 'up'
242
+ else if (direction === 'vertical') {
243
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
244
+ exitDirection = deltaY > 0 ? 'down' : 'up'
245
+ }
246
+ }
247
+ }
248
+
249
+ const shouldDismiss = exitDirection && (passedThreshold || hasVelocity)
250
+
251
+ // release pointer capture using stable ref
252
+ if (captureElementRef.current) {
253
+ try {
254
+ captureElementRef.current.releasePointerCapture(event.pointerId)
255
+ } catch {
256
+ // ignore if already released
257
+ }
258
+ }
259
+
260
+ // reset state
261
+ cleanup()
262
+
263
+ if (shouldDismiss && exitDirection) {
264
+ onDismiss(exitDirection, relevantVelocity)
265
+ } else {
266
+ onCancel()
267
+ }
268
+ },
269
+ [
270
+ disabled,
271
+ direction,
272
+ threshold,
273
+ isHorizontal,
274
+ isVertical,
275
+ onDismiss,
276
+ onCancel,
277
+ cleanup,
278
+ ]
279
+ )
280
+
281
+ const handlePointerCancel = React.useCallback(
282
+ (event: React.PointerEvent) => {
283
+ // release pointer capture using stable ref
284
+ if (captureElementRef.current) {
285
+ try {
286
+ captureElementRef.current.releasePointerCapture(event.pointerId)
287
+ } catch {
288
+ // ignore if already released
289
+ }
290
+ }
291
+
292
+ cleanup()
293
+ onCancel()
294
+ },
295
+ [onCancel, cleanup]
296
+ )
297
+
298
+ // Handle lost pointer capture - treat like cancel
299
+ const handleLostPointerCapture = React.useCallback(() => {
300
+ if (dragStartRef.current) {
301
+ cleanup()
302
+ onCancel()
303
+ }
304
+ }, [onCancel, cleanup])
305
+
306
+ const gestureHandlers = {
307
+ onPointerDown: handlePointerDown,
308
+ onPointerMove: handlePointerMove,
309
+ onPointerUp: handlePointerUp,
310
+ onPointerCancel: handlePointerCancel,
311
+ onLostPointerCapture: handleLostPointerCapture,
312
+ }
313
+
314
+ return {
315
+ isDragging,
316
+ gestureHandlers,
317
+ gesture: null as any,
318
+ }
319
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Hook to detect reduced motion preference.
3
+ * Returns true if user prefers reduced motion (via system settings or forced).
4
+ */
5
+
6
+ import { isWeb } from '@hanzogui/constants'
7
+ import * as React from 'react'
8
+
9
+ let cachedResult: boolean | null = null
10
+
11
+ function getReducedMotion(): boolean {
12
+ if (cachedResult !== null) return cachedResult
13
+
14
+ if (!isWeb) {
15
+ // on native, we could use AccessibilityInfo.isReduceMotionEnabled()
16
+ // but that requires async, so default to false
17
+ cachedResult = false
18
+ return false
19
+ }
20
+
21
+ if (typeof window === 'undefined') {
22
+ return false
23
+ }
24
+
25
+ cachedResult = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
26
+ return cachedResult
27
+ }
28
+
29
+ export function useReducedMotion(forceReducedMotion?: boolean): boolean {
30
+ const [reducedMotion, setReducedMotion] = React.useState(
31
+ () => forceReducedMotion ?? getReducedMotion()
32
+ )
33
+
34
+ React.useEffect(() => {
35
+ // if forced, use that value
36
+ if (forceReducedMotion !== undefined) {
37
+ setReducedMotion(forceReducedMotion)
38
+ return
39
+ }
40
+
41
+ // listen for changes to system preference
42
+ if (!isWeb || typeof window === 'undefined') return
43
+
44
+ const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)')
45
+ if (!mediaQuery) return
46
+
47
+ const handleChange = (e: MediaQueryListEvent) => {
48
+ cachedResult = e.matches
49
+ setReducedMotion(e.matches)
50
+ }
51
+
52
+ mediaQuery.addEventListener?.('change', handleChange)
53
+ return () => {
54
+ mediaQuery.removeEventListener?.('change', handleChange)
55
+ }
56
+ }, [forceReducedMotion])
57
+
58
+ return reducedMotion
59
+ }