@hanzogui/toast 8.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzogui/toast",
3
- "version": "8.0.0",
3
+ "version": "8.1.0",
4
4
  "source": "src/index.ts",
5
5
  "files": [
6
6
  "src",
@@ -44,25 +44,26 @@
44
44
  "clean:build": "hanzogui-build clean:build"
45
45
  },
46
46
  "dependencies": {
47
- "@hanzogui/animate-presence": "8.0.0",
48
- "@hanzogui/collection": "8.0.0",
49
- "@hanzogui/compose-refs": "8.0.0",
50
- "@hanzogui/constants": "8.0.0",
51
- "@hanzogui/core": "8.0.0",
52
- "@hanzogui/create-context": "8.0.0",
53
- "@hanzogui/dismissable": "8.0.0",
54
- "@hanzogui/helpers": "8.0.0",
55
- "@hanzogui/native": "8.0.0",
56
- "@hanzogui/polyfill-dev": "8.0.0",
57
- "@hanzogui/portal": "8.0.0",
58
- "@hanzogui/stacks": "8.0.0",
59
- "@hanzogui/start-transition": "8.0.0",
60
- "@hanzogui/text": "8.0.0",
61
- "@hanzogui/use-controllable-state": "8.0.0",
62
- "@hanzogui/visually-hidden": "8.0.0"
47
+ "@hanzogui/animate-presence": "8.1.0",
48
+ "@hanzogui/collection": "8.1.0",
49
+ "@hanzogui/compose-refs": "8.1.0",
50
+ "@hanzogui/constants": "8.1.0",
51
+ "@hanzogui/core": "8.1.0",
52
+ "@hanzogui/create-context": "8.1.0",
53
+ "@hanzogui/dismissable": "8.1.0",
54
+ "@hanzogui/helpers": "8.1.0",
55
+ "@hanzogui/native": "8.1.0",
56
+ "@hanzogui/polyfill-dev": "8.1.0",
57
+ "@hanzogui/portal": "8.1.0",
58
+ "@hanzogui/stacks": "8.1.0",
59
+ "@hanzogui/start-transition": "8.1.0",
60
+ "@hanzogui/text": "8.1.0",
61
+ "@hanzogui/use-controllable-state": "8.1.0",
62
+ "@hanzogui/visually-hidden": "8.1.0",
63
+ "@hanzogui/web": "8.1.0"
63
64
  },
64
65
  "devDependencies": {
65
- "@hanzogui/build": "8.0.0",
66
+ "@hanzogui/build": "8.1.0",
66
67
  "react": ">=19",
67
68
  "react-native": "0.83.2"
68
69
  },
@@ -76,5 +77,10 @@
76
77
  "./types/v2.d.ts"
77
78
  ]
78
79
  }
80
+ },
81
+ "repository": {
82
+ "type": "git",
83
+ "url": "git+https://github.com/hanzoai/gui.git",
84
+ "directory": "pkgs/ui/toast"
79
85
  }
80
86
  }
@@ -1203,6 +1203,23 @@ const ToastItemInner = ToastItemFrame.styleable<ToastItemProps>(
1203
1203
  dragRef={dragRef}
1204
1204
  >
1205
1205
  <ToastItemFrame
1206
+ // The one hook a stylesheet can reach a toast by.
1207
+ //
1208
+ // A toast is floating chrome — it is over the page or it is nothing
1209
+ // — so a design system wants to give it the same material every
1210
+ // other floating surface wears. It had no handle at all: `Toaster`
1211
+ // accepts sonner's `className`/`style`/`classNames` and documents
1212
+ // them as INERT, and nothing else here reaches the DOM node. So the
1213
+ // one surface that is unambiguously floating was the one surface a
1214
+ // sheet could not select, and it kept a hand-rolled
1215
+ // `rgba(0,0,0,0.15)` drop that is very nearly invisible on a dark
1216
+ // ground — the exact defect an elevation ladder exists to end.
1217
+ //
1218
+ // `data-slot` rather than a class or a prop because it needs no
1219
+ // decision from a call site: a toast is glass because it IS a toast.
1220
+ // gui forwards unrecognised props to the DOM node and native drops
1221
+ // them, which is what makes this cross-platform safe.
1222
+ data-slot="toast"
1206
1223
  role="status"
1207
1224
  aria-live="polite"
1208
1225
  aria-atomic
package/src/ToastImpl.tsx CHANGED
@@ -161,254 +161,251 @@ type ToastImplProps = ScopedProps<
161
161
  ToastImplPrivateProps & ToastImplFrameProps & ToastExtraProps
162
162
  >
163
163
 
164
- const ToastImpl = React.forwardRef<GuiElement, ToastImplProps>(
165
- (props, forwardedRef) => {
166
- const {
167
- scope,
168
- type = 'foreground',
169
- duration: durationProp,
170
- open,
171
- onClose,
172
- onEscapeKeyDown,
173
- onPause,
174
- onResume,
175
- onSwipeStart,
176
- onSwipeMove,
177
- onSwipeCancel,
178
- onSwipeEnd,
179
- viewportName = 'default',
180
- ...toastProps
181
- } = props
182
- const isPresent = useIsPresent()
183
- const context = useToastProviderContext(scope)
184
- const [node, setNode] = React.useState<GuiElement | null>(null)
185
- const composedRefs = useComposedRefs(forwardedRef, setNode)
186
- const duration = durationProp || context.duration
187
- const closeTimerStartTimeRef = React.useRef(0)
188
- const closeTimerRemainingTimeRef = React.useRef(duration)
189
- const closeTimerRef = React.useRef(0)
190
- const { onToastAdd, onToastRemove } = context
191
-
192
- const viewport = React.useMemo(() => {
193
- return context.viewports[viewportName] as HTMLElement | null | undefined
194
- }, [context.viewports, viewportName])
195
-
196
- const handleClose = useEvent(() => {
197
- if (!isPresent) {
198
- // already removed from the react tree
199
- return
200
- }
201
- // focus viewport if focus is within toast to read the remaining toast
202
- // count to SR users and ensure focus isn't lost
203
- if (isWeb) {
204
- const isFocusInToast = (node as unknown as HTMLDivElement)?.contains(
205
- document.activeElement
206
- )
207
- if (isFocusInToast) viewport?.focus()
208
- }
209
- onClose()
210
- })
164
+ const ToastImpl = React.forwardRef<GuiElement, ToastImplProps>((props, forwardedRef) => {
165
+ const {
166
+ scope,
167
+ type = 'foreground',
168
+ duration: durationProp,
169
+ open,
170
+ onClose,
171
+ onEscapeKeyDown,
172
+ onPause,
173
+ onResume,
174
+ onSwipeStart,
175
+ onSwipeMove,
176
+ onSwipeCancel,
177
+ onSwipeEnd,
178
+ viewportName = 'default',
179
+ ...toastProps
180
+ } = props
181
+ const isPresent = useIsPresent()
182
+ const context = useToastProviderContext(scope)
183
+ const [node, setNode] = React.useState<GuiElement | null>(null)
184
+ const composedRefs = useComposedRefs(forwardedRef, setNode)
185
+ const duration = durationProp || context.duration
186
+ const closeTimerStartTimeRef = React.useRef(0)
187
+ const closeTimerRemainingTimeRef = React.useRef(duration)
188
+ const closeTimerRef = React.useRef(0)
189
+ const { onToastAdd, onToastRemove } = context
190
+
191
+ const viewport = React.useMemo(() => {
192
+ return context.viewports[viewportName] as HTMLElement | null | undefined
193
+ }, [context.viewports, viewportName])
194
+
195
+ const handleClose = useEvent(() => {
196
+ if (!isPresent) {
197
+ // already removed from the react tree
198
+ return
199
+ }
200
+ // focus viewport if focus is within toast to read the remaining toast
201
+ // count to SR users and ensure focus isn't lost
202
+ if (isWeb) {
203
+ const isFocusInToast = (node as unknown as HTMLDivElement)?.contains(
204
+ document.activeElement
205
+ )
206
+ if (isFocusInToast) viewport?.focus()
207
+ }
208
+ onClose()
209
+ })
211
210
 
212
- const startTimer = React.useCallback(
213
- (duration: number) => {
214
- if (!duration || duration === Number.POSITIVE_INFINITY) return
215
- clearTimeout(closeTimerRef.current)
216
- closeTimerStartTimeRef.current = new Date().getTime()
217
- closeTimerRef.current = setTimeout(handleClose, duration) as unknown as number
218
- },
219
- [handleClose]
220
- )
221
-
222
- const handleResume = React.useCallback(() => {
223
- startTimer(closeTimerRemainingTimeRef.current)
224
- onResume?.()
225
- }, [onResume, startTimer])
226
-
227
- const handlePause = React.useCallback(() => {
228
- const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current
229
- closeTimerRemainingTimeRef.current =
230
- closeTimerRemainingTimeRef.current - elapsedTime
231
- window.clearTimeout(closeTimerRef.current)
232
- onPause?.()
233
- }, [onPause])
234
-
235
- React.useEffect(() => {
236
- if (!isWeb) return
237
-
238
- if (viewport) {
239
- viewport.addEventListener(VIEWPORT_PAUSE, handlePause)
240
- viewport.addEventListener(VIEWPORT_RESUME, handleResume)
241
- return () => {
242
- viewport.removeEventListener(VIEWPORT_PAUSE, handlePause)
243
- viewport.removeEventListener(VIEWPORT_RESUME, handleResume)
244
- }
245
- }
246
- }, [viewport, duration, onPause, onResume, startTimer])
247
-
248
- // start timer when toast opens or duration changes.
249
- // we include `open` in deps because closed !== unmounted when animating
250
- // so it could reopen before being completely unmounted
251
- React.useEffect(() => {
252
- if (open && !context.isClosePausedRef.current) {
253
- startTimer(duration)
211
+ const startTimer = React.useCallback(
212
+ (duration: number) => {
213
+ if (!duration || duration === Number.POSITIVE_INFINITY) return
214
+ clearTimeout(closeTimerRef.current)
215
+ closeTimerStartTimeRef.current = new Date().getTime()
216
+ closeTimerRef.current = setTimeout(handleClose, duration) as unknown as number
217
+ },
218
+ [handleClose]
219
+ )
220
+
221
+ const handleResume = React.useCallback(() => {
222
+ startTimer(closeTimerRemainingTimeRef.current)
223
+ onResume?.()
224
+ }, [onResume, startTimer])
225
+
226
+ const handlePause = React.useCallback(() => {
227
+ const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current
228
+ closeTimerRemainingTimeRef.current = closeTimerRemainingTimeRef.current - elapsedTime
229
+ window.clearTimeout(closeTimerRef.current)
230
+ onPause?.()
231
+ }, [onPause])
232
+
233
+ React.useEffect(() => {
234
+ if (!isWeb) return
235
+
236
+ if (viewport) {
237
+ viewport.addEventListener(VIEWPORT_PAUSE, handlePause)
238
+ viewport.addEventListener(VIEWPORT_RESUME, handleResume)
239
+ return () => {
240
+ viewport.removeEventListener(VIEWPORT_PAUSE, handlePause)
241
+ viewport.removeEventListener(VIEWPORT_RESUME, handleResume)
254
242
  }
255
- }, [open, duration, context.isClosePausedRef, startTimer])
243
+ }
244
+ }, [viewport, duration, onPause, onResume, startTimer])
245
+
246
+ // start timer when toast opens or duration changes.
247
+ // we include `open` in deps because closed !== unmounted when animating
248
+ // so it could reopen before being completely unmounted
249
+ React.useEffect(() => {
250
+ if (open && !context.isClosePausedRef.current) {
251
+ startTimer(duration)
252
+ }
253
+ }, [open, duration, context.isClosePausedRef, startTimer])
256
254
 
257
- React.useEffect(() => {
258
- onToastAdd()
259
- return () => onToastRemove()
260
- }, [onToastAdd, onToastRemove])
255
+ React.useEffect(() => {
256
+ onToastAdd()
257
+ return () => onToastRemove()
258
+ }, [onToastAdd, onToastRemove])
261
259
 
262
- const announceTextContent = React.useMemo(() => {
263
- if (!isWeb) return null
264
- return node ? getAnnounceTextContent(node as unknown as HTMLDivElement) : null
265
- }, [node])
260
+ const announceTextContent = React.useMemo(() => {
261
+ if (!isWeb) return null
262
+ return node ? getAnnounceTextContent(node as unknown as HTMLDivElement) : null
263
+ }, [node])
266
264
 
267
- const isHorizontalSwipe = ['left', 'right', 'horizontal'].includes(
268
- context.swipeDirection
269
- )
265
+ const isHorizontalSwipe = ['left', 'right', 'horizontal'].includes(
266
+ context.swipeDirection
267
+ )
270
268
 
271
- const { animationDriver } = useConfiguration()
272
- if (!animationDriver) {
273
- throw new Error('Must set animations in hanzogui.config.ts')
274
- }
269
+ const { animationDriver } = useConfiguration()
270
+ if (!animationDriver) {
271
+ throw new Error('Must set animations in hanzogui.config.ts')
272
+ }
275
273
 
276
- const { useAnimatedNumber, useAnimatedNumberStyle } = animationDriver
274
+ const { useAnimatedNumber, useAnimatedNumberStyle } = animationDriver
277
275
 
278
- const animatedNumber = useAnimatedNumber(0)
276
+ const animatedNumber = useAnimatedNumber(0)
279
277
 
280
- // temp until reanimated useAnimatedNumber fix
281
- const AnimatedView = (animationDriver['NumberView'] ??
282
- animationDriver.View ??
283
- View) as typeof Animated.View
278
+ // temp until reanimated useAnimatedNumber fix
279
+ const AnimatedView = (animationDriver['NumberView'] ??
280
+ animationDriver.View ??
281
+ View) as typeof Animated.View
284
282
 
285
- const animatedStyles = useAnimatedNumberStyle(animatedNumber, (val) => {
286
- 'worklet'
287
- return {
288
- transform: [isHorizontalSwipe ? { translateX: val } : { translateY: val }],
289
- }
290
- })
283
+ const animatedStyles = useAnimatedNumberStyle(animatedNumber, (val) => {
284
+ 'worklet'
285
+ return {
286
+ transform: [isHorizontalSwipe ? { translateX: val } : { translateY: val }],
287
+ }
288
+ })
291
289
 
292
- const panResponder = React.useMemo(() => {
293
- const PanResponder = getPanResponder()
294
- if (!PanResponder) return null
295
- return PanResponder.create({
296
- onMoveShouldSetPanResponder: (e, gesture) => {
297
- const shouldMove = shouldGrantGestureMove(context.swipeDirection, gesture)
298
- if (shouldMove) {
299
- onSwipeStart?.(e)
300
- return true
301
- }
302
- return false
303
- },
304
- onPanResponderGrant: (e) => {
290
+ const panResponder = React.useMemo(() => {
291
+ const PanResponder = getPanResponder()
292
+ if (!PanResponder) return null
293
+ return PanResponder.create({
294
+ onMoveShouldSetPanResponder: (e, gesture) => {
295
+ const shouldMove = shouldGrantGestureMove(context.swipeDirection, gesture)
296
+ if (shouldMove) {
297
+ onSwipeStart?.(e)
298
+ return true
299
+ }
300
+ return false
301
+ },
302
+ onPanResponderGrant: (e) => {
303
+ if (!isWeb) {
304
+ handlePause?.()
305
+ }
306
+ },
307
+ onPanResponderMove: (e, gesture) => {
308
+ const { x, y } = getGestureDistance(context.swipeDirection, gesture)
309
+ const delta = { x, y }
310
+ animatedNumber.setValue(isHorizontalSwipe ? x : y, { type: 'direct' })
311
+ if (isDeltaInDirection(delta, context.swipeDirection, context.swipeThreshold)) {
312
+ onSwipeEnd?.(e)
313
+ }
314
+ onSwipeMove?.(e)
315
+ },
316
+ onPanResponderEnd: (e, { dx, dy }) => {
317
+ if (
318
+ !isDeltaInDirection(
319
+ { x: dx, y: dy },
320
+ context.swipeDirection,
321
+ context.swipeThreshold
322
+ )
323
+ ) {
305
324
  if (!isWeb) {
306
- handlePause?.()
307
- }
308
- },
309
- onPanResponderMove: (e, gesture) => {
310
- const { x, y } = getGestureDistance(context.swipeDirection, gesture)
311
- const delta = { x, y }
312
- animatedNumber.setValue(isHorizontalSwipe ? x : y, { type: 'direct' })
313
- if (isDeltaInDirection(delta, context.swipeDirection, context.swipeThreshold)) {
314
- onSwipeEnd?.(e)
325
+ handleResume?.()
315
326
  }
316
- onSwipeMove?.(e)
317
- },
318
- onPanResponderEnd: (e, { dx, dy }) => {
319
- if (
320
- !isDeltaInDirection(
321
- { x: dx, y: dy },
322
- context.swipeDirection,
323
- context.swipeThreshold
324
- )
325
- ) {
326
- if (!isWeb) {
327
- handleResume?.()
328
- }
329
- onSwipeCancel?.(e)
330
- animatedNumber.setValue(0, { type: 'spring' })
331
- }
332
- },
333
- })
334
- }, [handlePause, handleResume])
335
-
336
- // need to get the theme name from context and apply it again since portals don't retain the theme
337
- const themeName = useThemeName()
338
-
339
- return (
340
- <>
341
- {announceTextContent && (
342
- <ToastAnnounce
343
- scope={scope}
344
- // Toasts are always role=status to avoid stuttering issues with role=alert in SRs.
345
- // biome-ignore lint/a11y/useSemanticElements: <explanation>
346
- role="status"
347
- aria-live={type === 'foreground' ? 'assertive' : 'polite'}
348
- aria-atomic
349
- >
350
- {announceTextContent}
351
- </ToastAnnounce>
352
- )}
353
-
354
- <PortalItem hostName={viewportName ?? 'default'}>
355
- <ToastInteractiveProvider
356
- key={props.id}
357
- scope={scope}
358
- onClose={() => {
359
- handleClose()
360
- }}
327
+ onSwipeCancel?.(e)
328
+ animatedNumber.setValue(0, { type: 'spring' })
329
+ }
330
+ },
331
+ })
332
+ }, [handlePause, handleResume])
333
+
334
+ // need to get the theme name from context and apply it again since portals don't retain the theme
335
+ const themeName = useThemeName()
336
+
337
+ return (
338
+ <>
339
+ {announceTextContent && (
340
+ <ToastAnnounce
341
+ scope={scope}
342
+ // Toasts are always role=status to avoid stuttering issues with role=alert in SRs.
343
+ // biome-ignore lint/a11y/useSemanticElements: <explanation>
344
+ role="status"
345
+ aria-live={type === 'foreground' ? 'assertive' : 'polite'}
346
+ aria-atomic
347
+ >
348
+ {announceTextContent}
349
+ </ToastAnnounce>
350
+ )}
351
+
352
+ <PortalItem hostName={viewportName ?? 'default'}>
353
+ <ToastInteractiveProvider
354
+ key={props.id}
355
+ scope={scope}
356
+ onClose={() => {
357
+ handleClose()
358
+ }}
359
+ >
360
+ <Dismissable
361
+ // asChild
362
+ onEscapeKeyDown={composeEventHandlers(onEscapeKeyDown, () => {
363
+ if (!context.isFocusedToastEscapeKeyDownRef.current) {
364
+ handleClose()
365
+ }
366
+ context.isFocusedToastEscapeKeyDownRef.current = false
367
+ })}
361
368
  >
362
- <Dismissable
363
- // asChild
364
- onEscapeKeyDown={composeEventHandlers(onEscapeKeyDown, () => {
365
- if (!context.isFocusedToastEscapeKeyDownRef.current) {
366
- handleClose()
367
- }
368
- context.isFocusedToastEscapeKeyDownRef.current = false
369
- })}
370
- >
371
- <Theme contain forceClassName name={themeName}>
372
- <AnimatedView
373
- {...panResponder?.panHandlers}
374
- style={[{ margin: 'auto' }, animatedStyles]}
375
- >
376
- <Collection.ItemSlot scope={context.toastScope}>
377
- <ToastImplFrame
378
- // Ensure toasts are announced as status list or status when focused
379
- role="status"
380
- aria-live="off"
381
- aria-atomic
382
- data-state={open ? 'open' : 'closed'}
383
- data-swipe-direction={context.swipeDirection}
384
- pointerEvents="auto"
385
- $platform-web={{
386
- touchAction: 'none',
387
- userSelect: 'none',
388
- }}
389
- {...toastProps}
390
- ref={composedRefs}
391
- {...(isWeb && {
392
- onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
393
- if (event.key !== 'Escape') return
394
- onEscapeKeyDown?.(event)
395
- if (!event.defaultPrevented) {
396
- context.isFocusedToastEscapeKeyDownRef.current = true
397
- handleClose()
398
- }
399
- }),
400
- })}
401
- />
402
- </Collection.ItemSlot>
403
- </AnimatedView>
404
- </Theme>
405
- </Dismissable>
406
- </ToastInteractiveProvider>
407
- </PortalItem>
408
- </>
409
- )
410
- }
411
- )
369
+ <Theme contain forceClassName name={themeName}>
370
+ <AnimatedView
371
+ {...panResponder?.panHandlers}
372
+ style={[{ margin: 'auto' }, animatedStyles]}
373
+ >
374
+ <Collection.ItemSlot scope={context.toastScope}>
375
+ <ToastImplFrame
376
+ // Ensure toasts are announced as status list or status when focused
377
+ role="status"
378
+ aria-live="off"
379
+ aria-atomic
380
+ data-state={open ? 'open' : 'closed'}
381
+ data-swipe-direction={context.swipeDirection}
382
+ pointerEvents="auto"
383
+ $platform-web={{
384
+ touchAction: 'none',
385
+ userSelect: 'none',
386
+ }}
387
+ {...toastProps}
388
+ ref={composedRefs}
389
+ {...(isWeb && {
390
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
391
+ if (event.key !== 'Escape') return
392
+ onEscapeKeyDown?.(event)
393
+ if (!event.defaultPrevented) {
394
+ context.isFocusedToastEscapeKeyDownRef.current = true
395
+ handleClose()
396
+ }
397
+ }),
398
+ })}
399
+ />
400
+ </Collection.ItemSlot>
401
+ </AnimatedView>
402
+ </Theme>
403
+ </Dismissable>
404
+ </ToastInteractiveProvider>
405
+ </PortalItem>
406
+ </>
407
+ )
408
+ })
412
409
 
413
410
  ToastImpl.propTypes = {
414
411
  type(props) {