@hanzogui/toast 2.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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/Toast.cjs +170 -0
  3. package/dist/cjs/Toast.native.js +174 -0
  4. package/dist/cjs/Toast.native.js.map +1 -0
  5. package/dist/cjs/ToastAnnounce.cjs +97 -0
  6. package/dist/cjs/ToastAnnounce.native.js +105 -0
  7. package/dist/cjs/ToastAnnounce.native.js.map +1 -0
  8. package/dist/cjs/ToastImperative.cjs +100 -0
  9. package/dist/cjs/ToastImperative.native.js +122 -0
  10. package/dist/cjs/ToastImperative.native.js.map +1 -0
  11. package/dist/cjs/ToastImpl.cjs +292 -0
  12. package/dist/cjs/ToastImpl.native.js +326 -0
  13. package/dist/cjs/ToastImpl.native.js.map +1 -0
  14. package/dist/cjs/ToastPortal.cjs +44 -0
  15. package/dist/cjs/ToastPortal.native.js +47 -0
  16. package/dist/cjs/ToastPortal.native.js.map +1 -0
  17. package/dist/cjs/ToastProvider.cjs +146 -0
  18. package/dist/cjs/ToastProvider.native.js +159 -0
  19. package/dist/cjs/ToastProvider.native.js.map +1 -0
  20. package/dist/cjs/ToastViewport.cjs +278 -0
  21. package/dist/cjs/ToastViewport.native.js +315 -0
  22. package/dist/cjs/ToastViewport.native.js.map +1 -0
  23. package/dist/cjs/constants.cjs +28 -0
  24. package/dist/cjs/constants.native.js +31 -0
  25. package/dist/cjs/constants.native.js.map +1 -0
  26. package/dist/cjs/createNativeToast.cjs +51 -0
  27. package/dist/cjs/createNativeToast.native.js +48 -0
  28. package/dist/cjs/createNativeToast.native.js.map +1 -0
  29. package/dist/cjs/index.cjs +18 -0
  30. package/dist/cjs/index.native.js +21 -0
  31. package/dist/cjs/index.native.js.map +1 -0
  32. package/dist/cjs/types.cjs +16 -0
  33. package/dist/cjs/types.native.js +19 -0
  34. package/dist/cjs/types.native.js.map +1 -0
  35. package/dist/esm/Toast.mjs +131 -0
  36. package/dist/esm/Toast.mjs.map +1 -0
  37. package/dist/esm/Toast.native.js +132 -0
  38. package/dist/esm/Toast.native.js.map +1 -0
  39. package/dist/esm/ToastAnnounce.mjs +62 -0
  40. package/dist/esm/ToastAnnounce.mjs.map +1 -0
  41. package/dist/esm/ToastAnnounce.native.js +67 -0
  42. package/dist/esm/ToastAnnounce.native.js.map +1 -0
  43. package/dist/esm/ToastImperative.mjs +63 -0
  44. package/dist/esm/ToastImperative.mjs.map +1 -0
  45. package/dist/esm/ToastImperative.native.js +82 -0
  46. package/dist/esm/ToastImperative.native.js.map +1 -0
  47. package/dist/esm/ToastImpl.mjs +256 -0
  48. package/dist/esm/ToastImpl.mjs.map +1 -0
  49. package/dist/esm/ToastImpl.native.js +287 -0
  50. package/dist/esm/ToastImpl.native.js.map +1 -0
  51. package/dist/esm/ToastPortal.mjs +21 -0
  52. package/dist/esm/ToastPortal.mjs.map +1 -0
  53. package/dist/esm/ToastPortal.native.js +21 -0
  54. package/dist/esm/ToastPortal.native.js.map +1 -0
  55. package/dist/esm/ToastProvider.mjs +108 -0
  56. package/dist/esm/ToastProvider.mjs.map +1 -0
  57. package/dist/esm/ToastProvider.native.js +118 -0
  58. package/dist/esm/ToastProvider.native.js.map +1 -0
  59. package/dist/esm/ToastViewport.mjs +241 -0
  60. package/dist/esm/ToastViewport.mjs.map +1 -0
  61. package/dist/esm/ToastViewport.native.js +275 -0
  62. package/dist/esm/ToastViewport.native.js.map +1 -0
  63. package/dist/esm/constants.mjs +4 -0
  64. package/dist/esm/constants.mjs.map +1 -0
  65. package/dist/esm/constants.native.js +4 -0
  66. package/dist/esm/constants.native.js.map +1 -0
  67. package/dist/esm/createNativeToast.mjs +27 -0
  68. package/dist/esm/createNativeToast.mjs.map +1 -0
  69. package/dist/esm/createNativeToast.native.js +21 -0
  70. package/dist/esm/createNativeToast.native.js.map +1 -0
  71. package/dist/esm/index.js +2 -0
  72. package/dist/esm/index.js.map +1 -0
  73. package/dist/esm/index.mjs +2 -0
  74. package/dist/esm/index.mjs.map +1 -0
  75. package/dist/esm/index.native.js +2 -0
  76. package/dist/esm/index.native.js.map +1 -0
  77. package/dist/esm/types.mjs +2 -0
  78. package/dist/esm/types.mjs.map +1 -0
  79. package/dist/esm/types.native.js +2 -0
  80. package/dist/esm/types.native.js.map +1 -0
  81. package/dist/jsx/Toast.mjs +131 -0
  82. package/dist/jsx/Toast.mjs.map +1 -0
  83. package/dist/jsx/Toast.native.js +174 -0
  84. package/dist/jsx/Toast.native.js.map +1 -0
  85. package/dist/jsx/ToastAnnounce.mjs +62 -0
  86. package/dist/jsx/ToastAnnounce.mjs.map +1 -0
  87. package/dist/jsx/ToastAnnounce.native.js +105 -0
  88. package/dist/jsx/ToastAnnounce.native.js.map +1 -0
  89. package/dist/jsx/ToastImperative.mjs +63 -0
  90. package/dist/jsx/ToastImperative.mjs.map +1 -0
  91. package/dist/jsx/ToastImperative.native.js +122 -0
  92. package/dist/jsx/ToastImperative.native.js.map +1 -0
  93. package/dist/jsx/ToastImpl.mjs +256 -0
  94. package/dist/jsx/ToastImpl.mjs.map +1 -0
  95. package/dist/jsx/ToastImpl.native.js +326 -0
  96. package/dist/jsx/ToastImpl.native.js.map +1 -0
  97. package/dist/jsx/ToastPortal.mjs +21 -0
  98. package/dist/jsx/ToastPortal.mjs.map +1 -0
  99. package/dist/jsx/ToastPortal.native.js +47 -0
  100. package/dist/jsx/ToastPortal.native.js.map +1 -0
  101. package/dist/jsx/ToastProvider.mjs +108 -0
  102. package/dist/jsx/ToastProvider.mjs.map +1 -0
  103. package/dist/jsx/ToastProvider.native.js +159 -0
  104. package/dist/jsx/ToastProvider.native.js.map +1 -0
  105. package/dist/jsx/ToastViewport.mjs +241 -0
  106. package/dist/jsx/ToastViewport.mjs.map +1 -0
  107. package/dist/jsx/ToastViewport.native.js +315 -0
  108. package/dist/jsx/ToastViewport.native.js.map +1 -0
  109. package/dist/jsx/constants.mjs +4 -0
  110. package/dist/jsx/constants.mjs.map +1 -0
  111. package/dist/jsx/constants.native.js +31 -0
  112. package/dist/jsx/constants.native.js.map +1 -0
  113. package/dist/jsx/createNativeToast.mjs +27 -0
  114. package/dist/jsx/createNativeToast.mjs.map +1 -0
  115. package/dist/jsx/createNativeToast.native.js +48 -0
  116. package/dist/jsx/createNativeToast.native.js.map +1 -0
  117. package/dist/jsx/index.js +2 -0
  118. package/dist/jsx/index.js.map +1 -0
  119. package/dist/jsx/index.mjs +2 -0
  120. package/dist/jsx/index.mjs.map +1 -0
  121. package/dist/jsx/index.native.js +21 -0
  122. package/dist/jsx/index.native.js.map +1 -0
  123. package/dist/jsx/types.mjs +2 -0
  124. package/dist/jsx/types.mjs.map +1 -0
  125. package/dist/jsx/types.native.js +19 -0
  126. package/dist/jsx/types.native.js.map +1 -0
  127. package/package.json +66 -0
  128. package/src/Toast.tsx +219 -0
  129. package/src/ToastAnnounce.tsx +103 -0
  130. package/src/ToastImperative.tsx +192 -0
  131. package/src/ToastImpl.tsx +502 -0
  132. package/src/ToastPortal.tsx +19 -0
  133. package/src/ToastProvider.tsx +197 -0
  134. package/src/ToastViewport.tsx +429 -0
  135. package/src/constants.ts +2 -0
  136. package/src/createNativeToast.native.tsx +29 -0
  137. package/src/createNativeToast.tsx +48 -0
  138. package/src/index.ts +3 -0
  139. package/src/types.ts +71 -0
  140. package/types/Toast.d.ts +84 -0
  141. package/types/ToastAnnounce.d.ts +18 -0
  142. package/types/ToastImperative.d.ts +95 -0
  143. package/types/ToastImpl.d.ts +109 -0
  144. package/types/ToastPortal.d.ts +8 -0
  145. package/types/ToastProvider.d.ts +92 -0
  146. package/types/ToastViewport.d.ts +75 -0
  147. package/types/constants.d.ts +3 -0
  148. package/types/createNativeToast.d.ts +4 -0
  149. package/types/createNativeToast.native.d.ts +4 -0
  150. package/types/index.d.ts +4 -0
  151. package/types/types.d.ts +61 -0
@@ -0,0 +1,197 @@
1
+ import { createCollection } from '@hanzogui/collection'
2
+ import type { NativeValue, GuiElement } from '@hanzogui/core'
3
+ import { createStyledContext } from '@hanzogui/core'
4
+ import { startTransition } from '@hanzogui/start-transition'
5
+ import * as React from 'react'
6
+
7
+ import { TOAST_CONTEXT } from './constants'
8
+ import type { ToastImperativeOptions } from './ToastImperative'
9
+ import { ToastImperativeProvider } from './ToastImperative'
10
+ import type { BurntToastOptions } from './types'
11
+
12
+ /* -------------------------------------------------------------------------------------------------
13
+ * ToastProvider
14
+ * -----------------------------------------------------------------------------------------------*/
15
+
16
+ const PROVIDER_NAME = 'ToastProvider'
17
+
18
+ const [Collection, useCollection] = createCollection<GuiElement>('Toast')
19
+
20
+ export type SwipeDirection = 'vertical' | 'up' | 'down' | 'horizontal' | 'left' | 'right'
21
+
22
+ export type ToastProviderContextValue = {
23
+ id: string
24
+ toastScope: string
25
+ label: string
26
+ duration: number
27
+ swipeDirection: SwipeDirection
28
+ swipeThreshold: number
29
+ toastCount: number
30
+ viewports: Record<string, GuiElement | null>
31
+ onViewportChange(name: string, viewport: GuiElement): void
32
+ onToastAdd(): void
33
+ onToastRemove(): void
34
+ isFocusedToastEscapeKeyDownRef: React.MutableRefObject<boolean>
35
+ isClosePausedRef: React.MutableRefObject<boolean>
36
+ options: ToastImperativeOptions
37
+ }
38
+
39
+ export type ToastScopes = string
40
+
41
+ type ScopedProps<P> = Omit<P, 'scope'> & { scope?: ToastScopes }
42
+
43
+ const { Provider: ToastProviderProvider, useStyledContext: useToastProviderContext } =
44
+ createStyledContext<ToastProviderContextValue>(
45
+ // since we always provide this we can avoid setting here
46
+ {} as ToastProviderContextValue,
47
+ 'Toast__'
48
+ )
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
+ * @defaultValue unique generated identifier
75
+ */
76
+ id?: string
77
+ /**
78
+ * Will show a native toast if is true or is set to the current platform. On iOS, it wraps `SPIndicator` and `SPAlert`. On Android, it wraps `ToastAndroid`. On web, it wraps Notification API. Mobile's native features are handled by `burnt`.
79
+ * Only works with the imperative `useToast` hook.
80
+ */
81
+ native?: NativeValue
82
+ /**
83
+ * Options for the burnt package if you're using native toasts on mobile
84
+ */
85
+ burntOptions?: Omit<BurntToastOptions, 'title' | 'message' | 'duration'>
86
+ /**
87
+ * Options for the notification API if you're using native toasts on web
88
+ */
89
+ notificationOptions?: NotificationOptions
90
+ }
91
+
92
+ const ToastProvider: React.FC<ToastProviderProps> = (
93
+ props: ScopedProps<ToastProviderProps>
94
+ ) => {
95
+ const {
96
+ scope = TOAST_CONTEXT,
97
+ id: providedId,
98
+ burntOptions,
99
+ native,
100
+ notificationOptions,
101
+ label = 'Notification',
102
+ duration = 5000,
103
+ swipeDirection = 'right',
104
+ swipeThreshold = 50,
105
+ children,
106
+ } = props
107
+ const backupId = React.useId()
108
+ const id = providedId ?? backupId
109
+ const [viewports, setViewports] = React.useState<
110
+ ToastProviderContextValue['viewports']
111
+ >({})
112
+ const [toastCount, setToastCount] = React.useState(0)
113
+ const isFocusedToastEscapeKeyDownRef = React.useRef(false)
114
+ const isClosePausedRef = React.useRef(false)
115
+
116
+ const handleViewportChange = React.useCallback(
117
+ (name: string, viewport: GuiElement | null) => {
118
+ startTransition(() => {
119
+ setViewports((prev) => ({ ...prev, [name]: viewport }))
120
+ })
121
+ },
122
+ []
123
+ )
124
+
125
+ // memo context to avoid expensive re-renders
126
+ const options = React.useMemo(() => {
127
+ return {
128
+ duration,
129
+ burntOptions,
130
+ native,
131
+ notificationOptions,
132
+ }
133
+ // nested simple object so JSON.stringify
134
+ }, [JSON.stringify([duration, burntOptions, native, notificationOptions])])
135
+
136
+ return (
137
+ <Collection.Provider scope={scope}>
138
+ <ToastProviderProvider
139
+ scope={scope}
140
+ id={id}
141
+ label={label}
142
+ duration={duration}
143
+ swipeDirection={swipeDirection}
144
+ swipeThreshold={swipeThreshold}
145
+ toastCount={toastCount}
146
+ viewports={viewports}
147
+ onViewportChange={handleViewportChange}
148
+ onToastAdd={React.useCallback(() => {
149
+ startTransition(() => {
150
+ setToastCount((prevCount) => prevCount + 1)
151
+ })
152
+ }, [])}
153
+ onToastRemove={React.useCallback(() => {
154
+ startTransition(() => {
155
+ setToastCount((prevCount) => prevCount - 1)
156
+ })
157
+ }, [])}
158
+ isFocusedToastEscapeKeyDownRef={isFocusedToastEscapeKeyDownRef}
159
+ isClosePausedRef={isClosePausedRef}
160
+ options={options}
161
+ >
162
+ <ToastImperativeProvider options={options}>{children}</ToastImperativeProvider>
163
+ </ToastProviderProvider>
164
+ </Collection.Provider>
165
+ )
166
+ }
167
+
168
+ export function ReprogapateToastProvider(props: {
169
+ children: React.ReactNode
170
+ context: ToastProviderContextValue
171
+ }) {
172
+ const { children, context } = props
173
+ return (
174
+ <Collection.Provider scope={context.toastScope}>
175
+ <ToastProviderProvider {...context}>
176
+ <ToastImperativeProvider options={context.options}>
177
+ {children}
178
+ </ToastImperativeProvider>
179
+ </ToastProviderProvider>
180
+ </Collection.Provider>
181
+ )
182
+ }
183
+
184
+ ToastProvider.propTypes = {
185
+ label(props) {
186
+ if (props.label && typeof props.label === 'string' && !props.label.trim()) {
187
+ const error = `Invalid prop \`label\` supplied to \`${PROVIDER_NAME}\`. Expected non-empty \`string\`.`
188
+ return new Error(error)
189
+ }
190
+ return null
191
+ },
192
+ }
193
+
194
+ ToastProvider.displayName = PROVIDER_NAME
195
+
196
+ export { Collection, ToastProvider, useCollection, useToastProviderContext }
197
+ export type { ScopedProps, ToastProviderProps }
@@ -0,0 +1,429 @@
1
+ import { AnimatePresence } from '@hanzogui/animate-presence'
2
+ import { useComposedRefs } from '@hanzogui/compose-refs'
3
+ import { isWeb } from '@hanzogui/constants'
4
+ import type { GetProps, GuiElement } from '@hanzogui/core'
5
+ import { styled } from '@hanzogui/core'
6
+ import { PortalHost } from '@hanzogui/portal'
7
+ import { YStack } from '@hanzogui/stacks'
8
+ import { VisuallyHidden } from '@hanzogui/visually-hidden'
9
+ import * as React from 'react'
10
+ import { TOAST_CONTEXT } from './constants'
11
+ import { ToastPortal } from './ToastPortal'
12
+ import type { ScopedProps, ToastProviderContextValue } from './ToastProvider'
13
+ import { Collection, useCollection, useToastProviderContext } from './ToastProvider'
14
+
15
+ const VIEWPORT_NAME = 'ToastViewport'
16
+ const VIEWPORT_DEFAULT_HOTKEY = ['F8']
17
+ const VIEWPORT_PAUSE = 'toast.viewportPause'
18
+ const VIEWPORT_RESUME = 'toast.viewportResume'
19
+
20
+ const ToastViewportWrapperFrame = styled(YStack, {
21
+ name: 'ViewportWrapper',
22
+
23
+ variants: {
24
+ unstyled: {
25
+ false: {
26
+ pointerEvents: 'box-none',
27
+ top: 0,
28
+ bottom: 0,
29
+ left: 0,
30
+ right: 0,
31
+ position: 'fixed',
32
+ maxWidth: '100%',
33
+ tabIndex: 0,
34
+ zIndex: 100000,
35
+ },
36
+ },
37
+ } as const,
38
+
39
+ defaultVariants: {
40
+ unstyled: process.env.HANZO_GUI_HEADLESS === '1',
41
+ },
42
+ })
43
+
44
+ const ToastViewportFrame = styled(YStack, {
45
+ name: VIEWPORT_NAME,
46
+
47
+ variants: {
48
+ unstyled: {
49
+ false: {
50
+ pointerEvents: 'box-none',
51
+ position: 'fixed',
52
+ maxWidth: '100%',
53
+ },
54
+ },
55
+ } as const,
56
+
57
+ defaultVariants: {
58
+ unstyled: process.env.HANZO_GUI_HEADLESS === '1',
59
+ },
60
+ })
61
+
62
+ type ToastViewportFrameProps = GetProps<typeof ToastViewportFrame>
63
+ type ToastViewportProps = ToastViewportFrameProps & {
64
+ /**
65
+ * The keys to use as the keyboard shortcut that will move focus to the toast viewport.
66
+ * @defaultValue ['F8']
67
+ */
68
+ hotkey?: string[]
69
+ /**
70
+ * An author-localized label for the toast viewport to provide context for screen reader users
71
+ * when navigating page landmarks. The available `{hotkey}` placeholder will be replaced for you.
72
+ * @defaultValue 'Notifications ({hotkey})'
73
+ */
74
+ label?: string
75
+ /**
76
+ * Used to reference the viewport if you want to have multiple viewports in the same provider.
77
+ */
78
+ name?: string
79
+ /**
80
+ * Pass this when you want to have multiple/duplicated toasts.
81
+ */
82
+ multipleToasts?: boolean
83
+ /**
84
+ * When true, uses a portal to render at the very top of the root GuiProvider.
85
+ */
86
+ portalToRoot?: boolean
87
+ }
88
+
89
+ const ToastViewport = React.memo(
90
+ React.forwardRef<HTMLDivElement, ToastViewportProps>(
91
+ (props: ScopedProps<ToastViewportProps>, forwardedRef) => {
92
+ const {
93
+ scope,
94
+ hotkey = VIEWPORT_DEFAULT_HOTKEY,
95
+ label = 'Notifications ({hotkey})',
96
+ name = 'default',
97
+ multipleToasts,
98
+ zIndex,
99
+ portalToRoot,
100
+ ...viewportProps
101
+ } = props
102
+ const context = useToastProviderContext(scope)
103
+ const getItems = useCollection(scope || TOAST_CONTEXT)
104
+ const headFocusProxyRef = React.useRef<FocusProxyElement>(null)
105
+ const tailFocusProxyRef = React.useRef<FocusProxyElement>(null)
106
+ const wrapperRef = React.useRef<HTMLDivElement>(null)
107
+ const ref = React.useRef<HTMLDivElement>(null)
108
+ const onViewportChange = React.useCallback(
109
+ (el: GuiElement) => {
110
+ if (context.viewports[name] !== el) context.onViewportChange(name, el)
111
+ },
112
+ [name, context.viewports]
113
+ )
114
+ // @ts-ignore TODO react 19 type needs fix
115
+ const composedRefs = useComposedRefs(forwardedRef, ref, onViewportChange)
116
+ const hotkeyLabel = hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, '')
117
+ const hasToasts = context.toastCount > 0
118
+
119
+ React.useEffect(() => {
120
+ if (!isWeb) return
121
+ if (context.toastCount === 0) return
122
+ const handleKeyDown = (event: KeyboardEvent) => {
123
+ // we use `event.code` as it is consistent regardless of meta keys that were pressed.
124
+ // for example, `event.key` for `Control+Alt+t` is `†` and `t !== †`
125
+ const isHotkeyPressed = hotkey.every(
126
+ (key) => (event as any)[key] || event.code === key
127
+ )
128
+ if (isHotkeyPressed) ref.current?.focus()
129
+ }
130
+ document.addEventListener('keydown', handleKeyDown)
131
+ return () => {
132
+ document.removeEventListener('keydown', handleKeyDown)
133
+ }
134
+ }, [hotkey, context.toastCount])
135
+
136
+ React.useEffect(() => {
137
+ if (!isWeb) return
138
+ if (context.toastCount === 0) return
139
+ const wrapper = wrapperRef.current
140
+ const viewport = ref.current
141
+ if (hasToasts && wrapper && viewport) {
142
+ const handlePause = () => {
143
+ if (!context.isClosePausedRef.current) {
144
+ const pauseEvent = new CustomEvent(VIEWPORT_PAUSE)
145
+ viewport.dispatchEvent(pauseEvent)
146
+ context.isClosePausedRef.current = true
147
+ }
148
+ }
149
+
150
+ const handleResume = () => {
151
+ if (context.isClosePausedRef.current) {
152
+ const resumeEvent = new CustomEvent(VIEWPORT_RESUME)
153
+ viewport.dispatchEvent(resumeEvent)
154
+ context.isClosePausedRef.current = false
155
+ }
156
+ }
157
+
158
+ const handleFocusOutResume = (event: FocusEvent) => {
159
+ const isFocusMovingOutside = !wrapper.contains(
160
+ event.relatedTarget as HTMLElement
161
+ )
162
+ if (isFocusMovingOutside) handleResume()
163
+ }
164
+
165
+ const handlePointerLeaveResume = () => {
166
+ const isFocusInside = wrapper.contains(document.activeElement)
167
+ if (!isFocusInside) handleResume()
168
+ }
169
+
170
+ // Toasts are not in the viewport React tree so we need to bind DOM events
171
+ wrapper.addEventListener('focusin', handlePause)
172
+ wrapper.addEventListener('focusout', handleFocusOutResume)
173
+ wrapper.addEventListener('pointermove', handlePause)
174
+ wrapper.addEventListener('pointerleave', handlePointerLeaveResume)
175
+ window.addEventListener('blur', handlePause)
176
+ window.addEventListener('focus', handleResume)
177
+ return () => {
178
+ wrapper.removeEventListener('focusin', handlePause)
179
+ wrapper.removeEventListener('focusout', handleFocusOutResume)
180
+ wrapper.removeEventListener('pointermove', handlePause)
181
+ wrapper.removeEventListener('pointerleave', handlePointerLeaveResume)
182
+ window.removeEventListener('blur', handlePause)
183
+ window.removeEventListener('focus', handleResume)
184
+ }
185
+ }
186
+ }, [hasToasts, context.isClosePausedRef, context.toastCount])
187
+
188
+ const getSortedTabbableCandidates = React.useCallback(
189
+ ({ tabbingDirection }: { tabbingDirection: 'forwards' | 'backwards' }) => {
190
+ const toastItems = getItems()
191
+ const tabbableCandidates = toastItems.map((toastItem) => {
192
+ const toastNode = toastItem.ref.current!
193
+ const toastTabbableCandidates = [
194
+ toastNode,
195
+ ...getTabbableCandidates(toastNode),
196
+ ]
197
+ return tabbingDirection === 'forwards'
198
+ ? toastTabbableCandidates
199
+ : toastTabbableCandidates.reverse()
200
+ })
201
+ return (
202
+ tabbingDirection === 'forwards'
203
+ ? tabbableCandidates.reverse()
204
+ : tabbableCandidates
205
+ ).flat()
206
+ },
207
+ [getItems]
208
+ )
209
+
210
+ React.useEffect(() => {
211
+ if (!isWeb) return
212
+ if (context.toastCount === 0) return
213
+
214
+ const viewport = ref.current
215
+ // We programmatically manage tabbing as we are unable to influence
216
+ // the source order with portals, this allows us to reverse the
217
+ // tab order so that it runs from most recent toast to least
218
+ if (viewport) {
219
+ const handleKeyDown = (event: KeyboardEvent) => {
220
+ const isMetaKey = event.altKey || event.ctrlKey || event.metaKey
221
+ const isTabKey = event.key === 'Tab' && !isMetaKey
222
+
223
+ if (isTabKey) {
224
+ const focusedElement = document.activeElement
225
+ const isTabbingBackwards = event.shiftKey
226
+ const targetIsViewport = event.target === viewport
227
+
228
+ // If we're back tabbing after jumping to the viewport then we simply
229
+ // proxy focus out to the preceding document
230
+ if (targetIsViewport && isTabbingBackwards) {
231
+ // @ts-ignore ali TODO type
232
+ headFocusProxyRef.current?.focus()
233
+ return
234
+ }
235
+
236
+ const tabbingDirection = isTabbingBackwards ? 'backwards' : 'forwards'
237
+ const sortedCandidates = getSortedTabbableCandidates({ tabbingDirection })
238
+ const index = sortedCandidates.findIndex(
239
+ (candidate) => candidate === focusedElement
240
+ )
241
+ if (focusFirst(sortedCandidates.slice(index + 1) as any)) {
242
+ event.preventDefault()
243
+ } else {
244
+ // If we can't focus that means we're at the edges so we
245
+ // proxy to the corresponding exit point and let the browser handle
246
+ // tab/shift+tab keypress and implicitly pass focus to the next valid element in the document
247
+ isTabbingBackwards
248
+ ? // @ts-ignore ali TODO type
249
+ headFocusProxyRef.current?.focus()
250
+ : // @ts-ignore ali TODO type
251
+ tailFocusProxyRef.current?.focus()
252
+ }
253
+ }
254
+ }
255
+
256
+ // Toasts are not in the viewport React tree so we need to bind DOM events
257
+ viewport.addEventListener('keydown', handleKeyDown)
258
+ return () => viewport.removeEventListener('keydown', handleKeyDown)
259
+ }
260
+ }, [getItems, getSortedTabbableCandidates, context.toastCount])
261
+
262
+ const contents = (
263
+ <ToastViewportWrapperFrame
264
+ ref={wrapperRef as any}
265
+ role="region"
266
+ aria-label={label.replace('{hotkey}', hotkeyLabel)}
267
+ // // Ensure virtual cursor from landmarks menus triggers focus/blur for pause/resume
268
+ tabIndex={-1}
269
+ // // incase list has size when empty (e.g. padding), we remove pointer events so
270
+ // // it doesn't prevent interactions with page elements that it overlays
271
+ // pointerEvents={hasToasts ? undefined : 'none'}
272
+ >
273
+ {hasToasts && (
274
+ <FocusProxy
275
+ context={context}
276
+ viewportName={name}
277
+ ref={headFocusProxyRef}
278
+ onFocusFromOutsideViewport={() => {
279
+ const tabbableCandidates = getSortedTabbableCandidates({
280
+ tabbingDirection: 'forwards',
281
+ })
282
+ focusFirst(tabbableCandidates as any)
283
+ }}
284
+ />
285
+ )}
286
+ {/**
287
+ * tabindex on the the list so that it can be focused when items are removed. we focus
288
+ * the list instead of the viewport so it announces number of items remaining.
289
+ */}
290
+ <Collection.Slot scope={context.toastScope}>
291
+ <ToastViewportFrame
292
+ focusable={context.toastCount > 0}
293
+ ref={composedRefs as any}
294
+ {...viewportProps}
295
+ >
296
+ <PortalHost
297
+ render={(children) => (
298
+ <AnimatePresence exitBeforeEnter={!multipleToasts}>
299
+ {children}
300
+ </AnimatePresence>
301
+ )}
302
+ name={name ?? 'default'}
303
+ />
304
+ </ToastViewportFrame>
305
+ </Collection.Slot>
306
+ {hasToasts && (
307
+ <FocusProxy
308
+ context={context}
309
+ viewportName={name}
310
+ ref={tailFocusProxyRef}
311
+ onFocusFromOutsideViewport={() => {
312
+ const tabbableCandidates = getSortedTabbableCandidates({
313
+ tabbingDirection: 'backwards',
314
+ })
315
+ focusFirst(tabbableCandidates as any)
316
+ }}
317
+ />
318
+ )}
319
+ </ToastViewportWrapperFrame>
320
+ )
321
+
322
+ if (portalToRoot) {
323
+ return (
324
+ <ToastPortal
325
+ context={context}
326
+ {...(typeof zIndex === 'number' ? { zIndex } : {})}
327
+ >
328
+ {contents}
329
+ </ToastPortal>
330
+ )
331
+ }
332
+
333
+ return contents
334
+ }
335
+ )
336
+ )
337
+
338
+ ToastViewport.displayName = VIEWPORT_NAME
339
+
340
+ /* -----------------------------------------------------------------------------------------------*/
341
+
342
+ const FOCUS_PROXY_NAME = 'ToastFocusProxy'
343
+
344
+ type FocusProxyElement = React.ElementRef<typeof VisuallyHidden>
345
+
346
+ type VisuallyHiddenProps = GetProps<typeof VisuallyHidden>
347
+
348
+ interface FocusProxyProps extends VisuallyHiddenProps {
349
+ onFocusFromOutsideViewport(): void
350
+ viewportName: string
351
+ context: ToastProviderContextValue
352
+ }
353
+
354
+ const FocusProxy = React.forwardRef<FocusProxyElement, ScopedProps<FocusProxyProps>>(
355
+ (props, forwardedRef) => {
356
+ const { onFocusFromOutsideViewport, viewportName, context, ...proxyProps } = props
357
+ const viewport = context.viewports[viewportName] as HTMLElement
358
+
359
+ return (
360
+ <VisuallyHidden
361
+ aria-hidden
362
+ tabIndex={0}
363
+ {...proxyProps}
364
+ ref={forwardedRef}
365
+ // Avoid page scrolling when focus is on the focus proxy
366
+ position="fixed"
367
+ onFocus={(event) => {
368
+ if (!isWeb) return
369
+ const prevFocusedElement = event.relatedTarget as HTMLElement | null
370
+ const isFocusFromOutsideViewport = !viewport?.contains(prevFocusedElement)
371
+ if (isFocusFromOutsideViewport) onFocusFromOutsideViewport()
372
+ }}
373
+ />
374
+ )
375
+ }
376
+ )
377
+
378
+ FocusProxy.displayName = FOCUS_PROXY_NAME
379
+
380
+ /* -----------------------------------------------------------------------------------------------*/
381
+
382
+ function focusFirst(candidates: GuiElement[]) {
383
+ if (!isWeb) return
384
+ const previouslyFocusedElement = document.activeElement
385
+ return candidates.some((candidate) => {
386
+ // if focus is already where we want to go, we don't want to keep going through the candidates
387
+ if (candidate === previouslyFocusedElement) return true
388
+ candidate.focus()
389
+ return document.activeElement !== previouslyFocusedElement
390
+ })
391
+ }
392
+
393
+ /**
394
+ * Returns a list of potential tabbable candidates.
395
+ *
396
+ * NOTE: This is only a close approximation. For example it doesn't take into account cases like when
397
+ * elements are not visible. This cannot be worked out easily by just reading a property, but rather
398
+ * necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
399
+ *
400
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
401
+ * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
402
+ */
403
+ function getTabbableCandidates(container: GuiElement) {
404
+ if (!isWeb) return []
405
+ const containerHtml = container as HTMLElement
406
+ const nodes: HTMLElement[] = []
407
+ const walker = document.createTreeWalker(containerHtml, NodeFilter.SHOW_ELEMENT, {
408
+ acceptNode: (node: any) => {
409
+ const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'
410
+ if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP
411
+ // `.tabIndex` is not the same as the `tabindex` attribute. It works on the
412
+ // runtime's understanding of tabbability, so this automatically accounts
413
+ // for any kind of element that could be tabbed to.
414
+ return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
415
+ },
416
+ })
417
+ while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement)
418
+ // we do not take into account the order of nodes with positive `tabIndex` as it
419
+ // hinders accessibility to have tab order different from visual order.
420
+ return nodes
421
+ }
422
+
423
+ export {
424
+ ToastViewport,
425
+ VIEWPORT_DEFAULT_HOTKEY,
426
+ VIEWPORT_PAUSE,
427
+ VIEWPORT_RESUME,
428
+ type ToastViewportProps,
429
+ }
@@ -0,0 +1,2 @@
1
+ export const TOAST_NAME = 'Toast'
2
+ export const TOAST_CONTEXT = 'Toast'
@@ -0,0 +1,29 @@
1
+ import { getBurnt } from '@hanzogui/native'
2
+ import type { CreateNativeToastsFn, HideNativeToastsFn } from './types'
3
+
4
+ export const createNativeToast: CreateNativeToastsFn = (
5
+ title,
6
+ { message, duration, burntOptions }
7
+ ) => {
8
+ const burnt = getBurnt()
9
+ if (!burnt.isEnabled) {
10
+ console.warn(
11
+ `Warning: Must call import '@hanzogui/native/setup-burnt' at your app entry point to use native toasts`
12
+ )
13
+ return false
14
+ }
15
+
16
+ burnt.state.toast!({
17
+ title,
18
+ message,
19
+ duration: duration ? duration / 1000 : undefined,
20
+ ...burntOptions,
21
+ })
22
+ return true
23
+ }
24
+
25
+ export const hideNativeToast: HideNativeToastsFn = () => {
26
+ const burnt = getBurnt()
27
+ if (!burnt.isEnabled) return
28
+ burnt.state.dismissAllAlerts!()
29
+ }