@octanejs/radix 0.1.2
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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +45 -0
- package/src/AccessibleIcon.ts +26 -0
- package/src/Accordion.ts +282 -0
- package/src/AlertDialog.ts +110 -0
- package/src/Arrow.ts +21 -0
- package/src/AspectRatio.ts +34 -0
- package/src/Avatar.ts +152 -0
- package/src/Checkbox.ts +325 -0
- package/src/Collapsible.ts +170 -0
- package/src/ContextMenu.ts +303 -0
- package/src/Dialog.ts +308 -0
- package/src/DismissableLayer.ts +413 -0
- package/src/DropdownMenu.ts +275 -0
- package/src/FocusScope.ts +266 -0
- package/src/Form.ts +621 -0
- package/src/HoverCard.ts +318 -0
- package/src/Label.ts +25 -0
- package/src/Menu.ts +1057 -0
- package/src/Menubar.ts +572 -0
- package/src/NavigationMenu.ts +1236 -0
- package/src/OneTimePasswordField.ts +977 -0
- package/src/PasswordToggleField.ts +495 -0
- package/src/Popover.ts +354 -0
- package/src/Popper.ts +401 -0
- package/src/Portal.ts +19 -0
- package/src/Presence.ts +225 -0
- package/src/Primitive.ts +53 -0
- package/src/Progress.ts +92 -0
- package/src/Radio.ts +262 -0
- package/src/RadioGroup.ts +212 -0
- package/src/RovingFocusGroup.ts +259 -0
- package/src/ScrollArea.ts +966 -0
- package/src/Select.ts +1901 -0
- package/src/Separator.ts +36 -0
- package/src/Slider.ts +745 -0
- package/src/Slot.ts +105 -0
- package/src/Switch.ts +278 -0
- package/src/Tabs.ts +177 -0
- package/src/Toast.ts +923 -0
- package/src/Toggle.ts +31 -0
- package/src/ToggleGroup.ts +157 -0
- package/src/Toolbar.ts +130 -0
- package/src/Tooltip.ts +669 -0
- package/src/VisuallyHidden.ts +29 -0
- package/src/collection.ts +97 -0
- package/src/compose-event-handlers.ts +15 -0
- package/src/compose-refs.ts +53 -0
- package/src/context.ts +109 -0
- package/src/direction.ts +19 -0
- package/src/focus-guards.ts +56 -0
- package/src/index.ts +54 -0
- package/src/internal.ts +42 -0
- package/src/scroll-lock.ts +57 -0
- package/src/use-callback-ref.ts +28 -0
- package/src/use-effect-event.ts +36 -0
- package/src/use-is-hydrated.ts +23 -0
- package/src/use-previous.ts +28 -0
- package/src/use-size.ts +57 -0
- package/src/useControllableState.ts +78 -0
- package/src/useId.ts +15 -0
package/src/Toast.ts
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-toast (source:
|
|
2
|
+
// .radix-primitives/packages/react/toast/src/toast.tsx). A toast notification system:
|
|
3
|
+
// `Provider` owns the shared config (label/duration/swipeDirection/swipeThreshold), the
|
|
4
|
+
// viewport element, the open-toast count and the pause/escape refs over a Collection of
|
|
5
|
+
// toasts; `Viewport` is a `role=region` wrapper (DismissableLayer.Branch) around an `ol`
|
|
6
|
+
// list with a document-level hotkey (F8) that focuses it, pause-on-hover/focus (+ window
|
|
7
|
+
// blur) via `toast.viewportPause`/`toast.viewportResume` custom events, and programmatic
|
|
8
|
+
// most-recent-first Tab order with VisuallyHidden head/tail FocusProxy exits; `Root`
|
|
9
|
+
// drives open state (useControllableState + Presence) around `ToastImpl` — a `li`
|
|
10
|
+
// portalled into the viewport, wrapped in DismissableLayer (Escape dismissal), with a
|
|
11
|
+
// pause/resume-aware close timer and pointer swipe handlers dispatching
|
|
12
|
+
// `toast.swipeStart/Move/Cancel/End` custom events; `Title`/`Description` are plain divs;
|
|
13
|
+
// `Action` requires `altText` (announce fallback) and composes `Close`, which closes via
|
|
14
|
+
// the interactive context; `ToastAnnounce` renders the toast's text content (excluding
|
|
15
|
+
// `ToastAnnounceExclude` subtrees) in a portalled VisuallyHidden live region for one
|
|
16
|
+
// second so screen readers announce it.
|
|
17
|
+
//
|
|
18
|
+
// octane adaptations (all previously established in this port series):
|
|
19
|
+
// - No forwardRef: `ref: forwardedRef` is destructured from props and composed with
|
|
20
|
+
// useComposedRefs (ref-as-prop, React-19 style).
|
|
21
|
+
// - Hook slots: plain-`.ts` components thread explicit `S()`/`subSlot()` slot symbols
|
|
22
|
+
// through every hook call (octane's auto-slotting pass only runs on compiled .tsx/.tsrx).
|
|
23
|
+
// - Events are NATIVE delegated DOM events: React's `event.nativeEvent` is the event
|
|
24
|
+
// itself, and swipe/announce handlers receive real CustomEvents.
|
|
25
|
+
// - `ReactDOM.createPortal` → octane's `createPortal`-as-a-value (renders at any position).
|
|
26
|
+
// - Fragments → keyed arrays (`ToastImpl` returns `[announce, interactive-portal]`).
|
|
27
|
+
// - `@radix-ui/react-use-layout-effect`'s SSR-safe wrapper → octane's `useLayoutEffect`.
|
|
28
|
+
// - Dev-only console.error surfaces are skipped per repo policy (Provider's empty-label
|
|
29
|
+
// check; Action's empty-altText message — the functional `return null` is kept).
|
|
30
|
+
// - Toasts are portalled INTO the viewport `ol` (an octane-rendered element) — the
|
|
31
|
+
// runtime's de-opt reconciler skips foreign `<!--portal-->` ranges (React parity:
|
|
32
|
+
// portal content coexists with the container's children; see
|
|
33
|
+
// octane tests/portal-into-deopt-host.test.ts).
|
|
34
|
+
import {
|
|
35
|
+
createElement,
|
|
36
|
+
createPortal,
|
|
37
|
+
useCallback,
|
|
38
|
+
useEffect,
|
|
39
|
+
useLayoutEffect,
|
|
40
|
+
useMemo,
|
|
41
|
+
useRef,
|
|
42
|
+
useState,
|
|
43
|
+
} from 'octane';
|
|
44
|
+
|
|
45
|
+
import { createCollection } from './collection';
|
|
46
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
47
|
+
import { useComposedRefs } from './compose-refs';
|
|
48
|
+
import { createContextScope } from './context';
|
|
49
|
+
import { DismissableLayer, DismissableLayerBranch } from './DismissableLayer';
|
|
50
|
+
import { S, subSlot } from './internal';
|
|
51
|
+
import { Portal as PortalPrimitive } from './Portal';
|
|
52
|
+
import { Presence } from './Presence';
|
|
53
|
+
import { dispatchDiscreteCustomEvent, Primitive } from './Primitive';
|
|
54
|
+
import { useCallbackRef } from './use-callback-ref';
|
|
55
|
+
import { useControllableState } from './useControllableState';
|
|
56
|
+
import { VisuallyHidden } from './VisuallyHidden';
|
|
57
|
+
|
|
58
|
+
/* -------------------------------------------------------------------------------------------------
|
|
59
|
+
* ToastProvider
|
|
60
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
61
|
+
|
|
62
|
+
const PROVIDER_NAME = 'ToastProvider';
|
|
63
|
+
|
|
64
|
+
const [Collection, useCollection, createCollectionScope] = createCollection('Toast');
|
|
65
|
+
|
|
66
|
+
type SwipeDirection = 'up' | 'down' | 'left' | 'right';
|
|
67
|
+
|
|
68
|
+
interface ToastProviderContextValue {
|
|
69
|
+
label: string;
|
|
70
|
+
duration: number;
|
|
71
|
+
swipeDirection: SwipeDirection;
|
|
72
|
+
swipeThreshold: number;
|
|
73
|
+
toastCount: number;
|
|
74
|
+
viewport: HTMLElement | null;
|
|
75
|
+
onViewportChange(viewport: HTMLElement | null): void;
|
|
76
|
+
onToastAdd(): void;
|
|
77
|
+
onToastRemove(): void;
|
|
78
|
+
isFocusedToastEscapeKeyDownRef: { current: boolean };
|
|
79
|
+
isClosePausedRef: { current: boolean };
|
|
80
|
+
announcerContainer?: Element | DocumentFragment;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [createToastContext, createToastScope] = createContextScope('Toast', [createCollectionScope]);
|
|
84
|
+
export { createToastScope };
|
|
85
|
+
const [ToastProviderProvider, useToastProviderContext] =
|
|
86
|
+
createToastContext<ToastProviderContextValue>(PROVIDER_NAME);
|
|
87
|
+
|
|
88
|
+
export function Provider(props: any): any {
|
|
89
|
+
const slot = S('Toast.Provider');
|
|
90
|
+
const {
|
|
91
|
+
__scopeToast,
|
|
92
|
+
label = 'Notification',
|
|
93
|
+
duration = 5000,
|
|
94
|
+
swipeDirection = 'right',
|
|
95
|
+
swipeThreshold = 50,
|
|
96
|
+
announcerContainer,
|
|
97
|
+
children,
|
|
98
|
+
} = props ?? {};
|
|
99
|
+
const [viewport, setViewport] = useState<HTMLElement | null>(null, subSlot(slot, 'viewport'));
|
|
100
|
+
const [toastCount, setToastCount] = useState(0, subSlot(slot, 'count'));
|
|
101
|
+
const isFocusedToastEscapeKeyDownRef = useRef(false, subSlot(slot, 'escKeyDown'));
|
|
102
|
+
const isClosePausedRef = useRef(false, subSlot(slot, 'paused'));
|
|
103
|
+
|
|
104
|
+
// (dev-only empty-`label` console.error intentionally not ported)
|
|
105
|
+
|
|
106
|
+
return createElement(Collection.Provider, {
|
|
107
|
+
scope: __scopeToast,
|
|
108
|
+
children: createElement(ToastProviderProvider, {
|
|
109
|
+
scope: __scopeToast,
|
|
110
|
+
label,
|
|
111
|
+
duration,
|
|
112
|
+
swipeDirection,
|
|
113
|
+
swipeThreshold,
|
|
114
|
+
toastCount,
|
|
115
|
+
viewport,
|
|
116
|
+
onViewportChange: setViewport,
|
|
117
|
+
onToastAdd: useCallback(
|
|
118
|
+
() => setToastCount((prevCount: number) => prevCount + 1),
|
|
119
|
+
[],
|
|
120
|
+
subSlot(slot, 'add'),
|
|
121
|
+
),
|
|
122
|
+
onToastRemove: useCallback(
|
|
123
|
+
() => setToastCount((prevCount: number) => prevCount - 1),
|
|
124
|
+
[],
|
|
125
|
+
subSlot(slot, 'remove'),
|
|
126
|
+
),
|
|
127
|
+
isFocusedToastEscapeKeyDownRef,
|
|
128
|
+
isClosePausedRef,
|
|
129
|
+
announcerContainer,
|
|
130
|
+
children,
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* -------------------------------------------------------------------------------------------------
|
|
136
|
+
* ToastViewport
|
|
137
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
138
|
+
|
|
139
|
+
const VIEWPORT_NAME = 'ToastViewport';
|
|
140
|
+
const VIEWPORT_DEFAULT_HOTKEY = ['F8'];
|
|
141
|
+
const VIEWPORT_PAUSE = 'toast.viewportPause';
|
|
142
|
+
const VIEWPORT_RESUME = 'toast.viewportResume';
|
|
143
|
+
|
|
144
|
+
export function Viewport(props: any): any {
|
|
145
|
+
const slot = S('Toast.Viewport');
|
|
146
|
+
const {
|
|
147
|
+
__scopeToast,
|
|
148
|
+
hotkey = VIEWPORT_DEFAULT_HOTKEY,
|
|
149
|
+
label = 'Notifications ({hotkey})',
|
|
150
|
+
ref: forwardedRef,
|
|
151
|
+
...viewportProps
|
|
152
|
+
} = props ?? {};
|
|
153
|
+
const context = useToastProviderContext(VIEWPORT_NAME, __scopeToast);
|
|
154
|
+
const getItems = useCollection(__scopeToast, subSlot(slot, 'items'));
|
|
155
|
+
const wrapperRef = useRef<HTMLElement | null>(null, subSlot(slot, 'wrapper'));
|
|
156
|
+
const headFocusProxyRef = useRef<HTMLElement | null>(null, subSlot(slot, 'head'));
|
|
157
|
+
const tailFocusProxyRef = useRef<HTMLElement | null>(null, subSlot(slot, 'tail'));
|
|
158
|
+
const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
|
|
159
|
+
const composedRefs = useComposedRefs(
|
|
160
|
+
forwardedRef,
|
|
161
|
+
ref,
|
|
162
|
+
context.onViewportChange,
|
|
163
|
+
subSlot(slot, 'refs'),
|
|
164
|
+
);
|
|
165
|
+
const hotkeyLabel = hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, '');
|
|
166
|
+
const hasToasts = context.toastCount > 0;
|
|
167
|
+
|
|
168
|
+
useEffect(
|
|
169
|
+
() => {
|
|
170
|
+
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
171
|
+
// we use `event.code` as it is consistent regardless of meta keys that were pressed.
|
|
172
|
+
// for example, `event.key` for `Control+Alt+t` is `†` and `t !== †`
|
|
173
|
+
const isHotkeyPressed =
|
|
174
|
+
hotkey.length !== 0 &&
|
|
175
|
+
hotkey.every((key: string) => (event as any)[key] || event.code === key);
|
|
176
|
+
if (isHotkeyPressed) ref.current?.focus();
|
|
177
|
+
};
|
|
178
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
179
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
180
|
+
},
|
|
181
|
+
[hotkey],
|
|
182
|
+
subSlot(slot, 'e:hotkey'),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
useEffect(
|
|
186
|
+
() => {
|
|
187
|
+
const wrapper = wrapperRef.current;
|
|
188
|
+
const viewport = ref.current;
|
|
189
|
+
if (hasToasts && wrapper && viewport) {
|
|
190
|
+
const handlePause = (): void => {
|
|
191
|
+
if (!context.isClosePausedRef.current) {
|
|
192
|
+
const pauseEvent = new CustomEvent(VIEWPORT_PAUSE);
|
|
193
|
+
viewport.dispatchEvent(pauseEvent);
|
|
194
|
+
context.isClosePausedRef.current = true;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const handleResume = (): void => {
|
|
199
|
+
if (context.isClosePausedRef.current) {
|
|
200
|
+
const resumeEvent = new CustomEvent(VIEWPORT_RESUME);
|
|
201
|
+
viewport.dispatchEvent(resumeEvent);
|
|
202
|
+
context.isClosePausedRef.current = false;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const handleFocusOutResume = (event: FocusEvent): void => {
|
|
207
|
+
const isFocusMovingOutside = !wrapper.contains(event.relatedTarget as HTMLElement);
|
|
208
|
+
if (isFocusMovingOutside) handleResume();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handlePointerLeaveResume = (): void => {
|
|
212
|
+
const isFocusInside = wrapper.contains(document.activeElement);
|
|
213
|
+
if (!isFocusInside) handleResume();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Toasts are not in the viewport [component] tree so we need to bind DOM events
|
|
217
|
+
wrapper.addEventListener('focusin', handlePause);
|
|
218
|
+
wrapper.addEventListener('focusout', handleFocusOutResume);
|
|
219
|
+
wrapper.addEventListener('pointermove', handlePause);
|
|
220
|
+
wrapper.addEventListener('pointerleave', handlePointerLeaveResume);
|
|
221
|
+
window.addEventListener('blur', handlePause);
|
|
222
|
+
window.addEventListener('focus', handleResume);
|
|
223
|
+
return () => {
|
|
224
|
+
wrapper.removeEventListener('focusin', handlePause);
|
|
225
|
+
wrapper.removeEventListener('focusout', handleFocusOutResume);
|
|
226
|
+
wrapper.removeEventListener('pointermove', handlePause);
|
|
227
|
+
wrapper.removeEventListener('pointerleave', handlePointerLeaveResume);
|
|
228
|
+
window.removeEventListener('blur', handlePause);
|
|
229
|
+
window.removeEventListener('focus', handleResume);
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[hasToasts, context.isClosePausedRef],
|
|
234
|
+
subSlot(slot, 'e:pause'),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const getSortedTabbableCandidates = useCallback(
|
|
238
|
+
({ tabbingDirection }: { tabbingDirection: 'forwards' | 'backwards' }) => {
|
|
239
|
+
const toastItems = getItems();
|
|
240
|
+
const tabbableCandidates = toastItems.map((toastItem: any) => {
|
|
241
|
+
const toastNode = toastItem.ref.current!;
|
|
242
|
+
const toastTabbableCandidates = [toastNode, ...getTabbableCandidates(toastNode)];
|
|
243
|
+
return tabbingDirection === 'forwards'
|
|
244
|
+
? toastTabbableCandidates
|
|
245
|
+
: toastTabbableCandidates.reverse();
|
|
246
|
+
});
|
|
247
|
+
return (
|
|
248
|
+
tabbingDirection === 'forwards' ? tabbableCandidates.reverse() : tabbableCandidates
|
|
249
|
+
).flat();
|
|
250
|
+
},
|
|
251
|
+
[getItems],
|
|
252
|
+
subSlot(slot, 'sorted'),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
useEffect(
|
|
256
|
+
() => {
|
|
257
|
+
const viewport = ref.current;
|
|
258
|
+
// We programmatically manage tabbing as we are unable to influence
|
|
259
|
+
// the source order with portals, this allows us to reverse the
|
|
260
|
+
// tab order so that it runs from most recent toast to least
|
|
261
|
+
if (viewport) {
|
|
262
|
+
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
263
|
+
const isMetaKey = event.altKey || event.ctrlKey || event.metaKey;
|
|
264
|
+
const isTabKey = event.key === 'Tab' && !isMetaKey;
|
|
265
|
+
|
|
266
|
+
if (isTabKey) {
|
|
267
|
+
const focusedElement = document.activeElement;
|
|
268
|
+
const isTabbingBackwards = event.shiftKey;
|
|
269
|
+
const targetIsViewport = event.target === viewport;
|
|
270
|
+
|
|
271
|
+
// If we're back tabbing after jumping to the viewport then we simply
|
|
272
|
+
// proxy focus out to the preceding document
|
|
273
|
+
if (targetIsViewport && isTabbingBackwards) {
|
|
274
|
+
headFocusProxyRef.current?.focus();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const tabbingDirection = isTabbingBackwards ? 'backwards' : 'forwards';
|
|
279
|
+
const sortedCandidates = getSortedTabbableCandidates({ tabbingDirection });
|
|
280
|
+
const index = sortedCandidates.findIndex(
|
|
281
|
+
(candidate: Element) => candidate === focusedElement,
|
|
282
|
+
);
|
|
283
|
+
if (focusFirst(sortedCandidates.slice(index + 1))) {
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
} else {
|
|
286
|
+
// If we can't focus that means we're at the edges so we
|
|
287
|
+
// proxy to the corresponding exit point and let the browser handle
|
|
288
|
+
// tab/shift+tab keypress and implicitly pass focus to the next valid element in the document
|
|
289
|
+
isTabbingBackwards
|
|
290
|
+
? headFocusProxyRef.current?.focus()
|
|
291
|
+
: tailFocusProxyRef.current?.focus();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Toasts are not in the viewport [component] tree so we need to bind DOM events
|
|
297
|
+
viewport.addEventListener('keydown', handleKeyDown);
|
|
298
|
+
return () => viewport.removeEventListener('keydown', handleKeyDown);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
[getItems, getSortedTabbableCandidates],
|
|
302
|
+
subSlot(slot, 'e:tab'),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
return createElement(DismissableLayerBranch, {
|
|
306
|
+
ref: wrapperRef,
|
|
307
|
+
role: 'region',
|
|
308
|
+
'aria-label': label.replace('{hotkey}', hotkeyLabel),
|
|
309
|
+
// Ensure virtual cursor from landmarks menus triggers focus/blur for pause/resume
|
|
310
|
+
tabIndex: -1,
|
|
311
|
+
// incase list has size when empty (e.g. padding), we remove pointer events so
|
|
312
|
+
// it doesn't prevent interactions with page elements that it overlays
|
|
313
|
+
style: { pointerEvents: hasToasts ? undefined : 'none' },
|
|
314
|
+
children: [
|
|
315
|
+
hasToasts
|
|
316
|
+
? createElement(FocusProxy, {
|
|
317
|
+
key: 'head',
|
|
318
|
+
__scopeToast,
|
|
319
|
+
ref: headFocusProxyRef,
|
|
320
|
+
onFocusFromOutsideViewport: () => {
|
|
321
|
+
const tabbableCandidates = getSortedTabbableCandidates({
|
|
322
|
+
tabbingDirection: 'forwards',
|
|
323
|
+
});
|
|
324
|
+
focusFirst(tabbableCandidates);
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
: null,
|
|
328
|
+
/**
|
|
329
|
+
* tabindex on the the list so that it can be focused when items are removed. we focus
|
|
330
|
+
* the list instead of the viewport so it announces number of items remaining.
|
|
331
|
+
*/
|
|
332
|
+
createElement(Collection.Slot, {
|
|
333
|
+
key: 'list',
|
|
334
|
+
scope: __scopeToast,
|
|
335
|
+
children: createElement(Primitive.ol, {
|
|
336
|
+
tabIndex: -1,
|
|
337
|
+
...viewportProps,
|
|
338
|
+
ref: composedRefs,
|
|
339
|
+
// the runtime doesn't clobber the portalled toasts on re-render.
|
|
340
|
+
children: viewportProps.children,
|
|
341
|
+
}),
|
|
342
|
+
}),
|
|
343
|
+
hasToasts
|
|
344
|
+
? createElement(FocusProxy, {
|
|
345
|
+
key: 'tail',
|
|
346
|
+
__scopeToast,
|
|
347
|
+
ref: tailFocusProxyRef,
|
|
348
|
+
onFocusFromOutsideViewport: () => {
|
|
349
|
+
const tabbableCandidates = getSortedTabbableCandidates({
|
|
350
|
+
tabbingDirection: 'backwards',
|
|
351
|
+
});
|
|
352
|
+
focusFirst(tabbableCandidates);
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
: null,
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* -----------------------------------------------------------------------------------------------*/
|
|
361
|
+
|
|
362
|
+
const FOCUS_PROXY_NAME = 'ToastFocusProxy';
|
|
363
|
+
|
|
364
|
+
function FocusProxy(props: any): any {
|
|
365
|
+
const {
|
|
366
|
+
__scopeToast,
|
|
367
|
+
onFocusFromOutsideViewport,
|
|
368
|
+
ref: forwardedRef,
|
|
369
|
+
...proxyProps
|
|
370
|
+
} = props ?? {};
|
|
371
|
+
const context = useToastProviderContext(FOCUS_PROXY_NAME, __scopeToast);
|
|
372
|
+
|
|
373
|
+
return createElement(VisuallyHidden, {
|
|
374
|
+
tabIndex: 0,
|
|
375
|
+
...proxyProps,
|
|
376
|
+
ref: forwardedRef,
|
|
377
|
+
// Avoid page scrolling when focus is on the focus proxy
|
|
378
|
+
style: { position: 'fixed' },
|
|
379
|
+
onFocus: (event: FocusEvent) => {
|
|
380
|
+
const prevFocusedElement = event.relatedTarget as HTMLElement | null;
|
|
381
|
+
const isFocusFromOutsideViewport = !context.viewport?.contains(prevFocusedElement);
|
|
382
|
+
if (isFocusFromOutsideViewport) onFocusFromOutsideViewport();
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* -------------------------------------------------------------------------------------------------
|
|
388
|
+
* Toast
|
|
389
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
390
|
+
|
|
391
|
+
const TOAST_NAME = 'Toast';
|
|
392
|
+
const TOAST_SWIPE_START = 'toast.swipeStart';
|
|
393
|
+
const TOAST_SWIPE_MOVE = 'toast.swipeMove';
|
|
394
|
+
const TOAST_SWIPE_CANCEL = 'toast.swipeCancel';
|
|
395
|
+
const TOAST_SWIPE_END = 'toast.swipeEnd';
|
|
396
|
+
|
|
397
|
+
export function Root(props: any): any {
|
|
398
|
+
const slot = S('Toast.Root');
|
|
399
|
+
const {
|
|
400
|
+
forceMount,
|
|
401
|
+
open: openProp,
|
|
402
|
+
defaultOpen,
|
|
403
|
+
onOpenChange,
|
|
404
|
+
ref: forwardedRef,
|
|
405
|
+
...toastProps
|
|
406
|
+
} = props ?? {};
|
|
407
|
+
const [open, setOpen] = useControllableState<boolean>(
|
|
408
|
+
{ prop: openProp, defaultProp: defaultOpen ?? true, onChange: onOpenChange },
|
|
409
|
+
subSlot(slot, 'open'),
|
|
410
|
+
);
|
|
411
|
+
return createElement(Presence, {
|
|
412
|
+
present: forceMount || open,
|
|
413
|
+
children: createElement(ToastImpl, {
|
|
414
|
+
open,
|
|
415
|
+
...toastProps,
|
|
416
|
+
ref: forwardedRef,
|
|
417
|
+
onClose: () => setOpen(false),
|
|
418
|
+
onPause: useCallbackRef(props?.onPause, subSlot(slot, 'pause')),
|
|
419
|
+
onResume: useCallbackRef(props?.onResume, subSlot(slot, 'resume')),
|
|
420
|
+
onSwipeStart: composeEventHandlers(props?.onSwipeStart, (event: any) => {
|
|
421
|
+
event.currentTarget.setAttribute('data-swipe', 'start');
|
|
422
|
+
}),
|
|
423
|
+
onSwipeMove: composeEventHandlers(props?.onSwipeMove, (event: any) => {
|
|
424
|
+
const { x, y } = event.detail.delta;
|
|
425
|
+
event.currentTarget.setAttribute('data-swipe', 'move');
|
|
426
|
+
event.currentTarget.style.setProperty('--radix-toast-swipe-move-x', `${x}px`);
|
|
427
|
+
event.currentTarget.style.setProperty('--radix-toast-swipe-move-y', `${y}px`);
|
|
428
|
+
}),
|
|
429
|
+
onSwipeCancel: composeEventHandlers(props?.onSwipeCancel, (event: any) => {
|
|
430
|
+
event.currentTarget.setAttribute('data-swipe', 'cancel');
|
|
431
|
+
event.currentTarget.style.removeProperty('--radix-toast-swipe-move-x');
|
|
432
|
+
event.currentTarget.style.removeProperty('--radix-toast-swipe-move-y');
|
|
433
|
+
event.currentTarget.style.removeProperty('--radix-toast-swipe-end-x');
|
|
434
|
+
event.currentTarget.style.removeProperty('--radix-toast-swipe-end-y');
|
|
435
|
+
}),
|
|
436
|
+
onSwipeEnd: composeEventHandlers(props?.onSwipeEnd, (event: any) => {
|
|
437
|
+
const { x, y } = event.detail.delta;
|
|
438
|
+
event.currentTarget.setAttribute('data-swipe', 'end');
|
|
439
|
+
event.currentTarget.style.removeProperty('--radix-toast-swipe-move-x');
|
|
440
|
+
event.currentTarget.style.removeProperty('--radix-toast-swipe-move-y');
|
|
441
|
+
event.currentTarget.style.setProperty('--radix-toast-swipe-end-x', `${x}px`);
|
|
442
|
+
event.currentTarget.style.setProperty('--radix-toast-swipe-end-y', `${y}px`);
|
|
443
|
+
setOpen(false);
|
|
444
|
+
}),
|
|
445
|
+
}),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* -----------------------------------------------------------------------------------------------*/
|
|
450
|
+
|
|
451
|
+
const [ToastInteractiveProvider, useToastInteractiveContext] = createToastContext(TOAST_NAME, {
|
|
452
|
+
onClose() {},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
function ToastImpl(props: any): any {
|
|
456
|
+
const slot = S('Toast.Impl');
|
|
457
|
+
const {
|
|
458
|
+
__scopeToast,
|
|
459
|
+
type = 'foreground',
|
|
460
|
+
duration: durationProp,
|
|
461
|
+
open,
|
|
462
|
+
onClose,
|
|
463
|
+
onEscapeKeyDown,
|
|
464
|
+
onPause,
|
|
465
|
+
onResume,
|
|
466
|
+
onSwipeStart,
|
|
467
|
+
onSwipeMove,
|
|
468
|
+
onSwipeCancel,
|
|
469
|
+
onSwipeEnd,
|
|
470
|
+
ref: forwardedRef,
|
|
471
|
+
...toastProps
|
|
472
|
+
} = props ?? {};
|
|
473
|
+
const context = useToastProviderContext(TOAST_NAME, __scopeToast);
|
|
474
|
+
const [node, setNode] = useState<HTMLElement | null>(null, subSlot(slot, 'node'));
|
|
475
|
+
const composedRefs = useComposedRefs(forwardedRef, setNode, subSlot(slot, 'refs'));
|
|
476
|
+
const pointerStartRef = useRef<{ x: number; y: number } | null>(null, subSlot(slot, 'pStart'));
|
|
477
|
+
const swipeDeltaRef = useRef<{ x: number; y: number } | null>(null, subSlot(slot, 'delta'));
|
|
478
|
+
const duration = durationProp || context.duration;
|
|
479
|
+
const closeTimerStartTimeRef = useRef(0, subSlot(slot, 'timerStart'));
|
|
480
|
+
const closeTimerRemainingTimeRef = useRef(duration, subSlot(slot, 'timerRemaining'));
|
|
481
|
+
const closeTimerRef = useRef(0, subSlot(slot, 'timer'));
|
|
482
|
+
const { onToastAdd, onToastRemove } = context;
|
|
483
|
+
const handleClose = useCallbackRef(
|
|
484
|
+
() => {
|
|
485
|
+
// focus viewport if focus is within toast to read the remaining toast
|
|
486
|
+
// count to SR users and ensure focus isn't lost
|
|
487
|
+
const isFocusInToast = node?.contains(document.activeElement);
|
|
488
|
+
if (isFocusInToast) context.viewport?.focus();
|
|
489
|
+
onClose();
|
|
490
|
+
},
|
|
491
|
+
subSlot(slot, 'close'),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const startTimer = useCallback(
|
|
495
|
+
(duration: number) => {
|
|
496
|
+
if (!duration || duration === Infinity) return;
|
|
497
|
+
window.clearTimeout(closeTimerRef.current);
|
|
498
|
+
closeTimerStartTimeRef.current = new Date().getTime();
|
|
499
|
+
closeTimerRef.current = window.setTimeout(handleClose, duration);
|
|
500
|
+
},
|
|
501
|
+
[handleClose],
|
|
502
|
+
subSlot(slot, 'startTimer'),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
useEffect(
|
|
506
|
+
() => {
|
|
507
|
+
const viewport = context.viewport;
|
|
508
|
+
if (viewport) {
|
|
509
|
+
const handleResume = (): void => {
|
|
510
|
+
startTimer(closeTimerRemainingTimeRef.current);
|
|
511
|
+
onResume?.();
|
|
512
|
+
};
|
|
513
|
+
const handlePause = (): void => {
|
|
514
|
+
const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current;
|
|
515
|
+
closeTimerRemainingTimeRef.current = closeTimerRemainingTimeRef.current - elapsedTime;
|
|
516
|
+
window.clearTimeout(closeTimerRef.current);
|
|
517
|
+
onPause?.();
|
|
518
|
+
};
|
|
519
|
+
viewport.addEventListener(VIEWPORT_PAUSE, handlePause);
|
|
520
|
+
viewport.addEventListener(VIEWPORT_RESUME, handleResume);
|
|
521
|
+
return () => {
|
|
522
|
+
viewport.removeEventListener(VIEWPORT_PAUSE, handlePause);
|
|
523
|
+
viewport.removeEventListener(VIEWPORT_RESUME, handleResume);
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
[context.viewport, duration, onPause, onResume, startTimer],
|
|
528
|
+
subSlot(slot, 'e:viewport'),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
// start timer when toast opens or duration changes.
|
|
532
|
+
// we include `open` in deps because closed !== unmounted when animating
|
|
533
|
+
// so it could reopen before being completely unmounted
|
|
534
|
+
useEffect(
|
|
535
|
+
() => {
|
|
536
|
+
if (open && !context.isClosePausedRef.current) startTimer(duration);
|
|
537
|
+
},
|
|
538
|
+
[open, duration, context.isClosePausedRef, startTimer],
|
|
539
|
+
subSlot(slot, 'e:open'),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
// Clear close timer on unmount to prevent memory leaks and errors in test environments
|
|
543
|
+
useEffect(
|
|
544
|
+
() => {
|
|
545
|
+
return () => {
|
|
546
|
+
window.clearTimeout(closeTimerRef.current);
|
|
547
|
+
};
|
|
548
|
+
},
|
|
549
|
+
[],
|
|
550
|
+
subSlot(slot, 'e:clear'),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
useEffect(
|
|
554
|
+
() => {
|
|
555
|
+
onToastAdd();
|
|
556
|
+
return () => onToastRemove();
|
|
557
|
+
},
|
|
558
|
+
[onToastAdd, onToastRemove],
|
|
559
|
+
subSlot(slot, 'e:count'),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const announceTextContent = useMemo(
|
|
563
|
+
() => (node ? getAnnounceTextContent(node) : null),
|
|
564
|
+
[node],
|
|
565
|
+
subSlot(slot, 'announce'),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
if (!context.viewport) return null;
|
|
569
|
+
|
|
570
|
+
return [
|
|
571
|
+
announceTextContent
|
|
572
|
+
? createElement(ToastAnnounce, {
|
|
573
|
+
key: 'announce',
|
|
574
|
+
__scopeToast,
|
|
575
|
+
// Toasts are always role=status to avoid stuttering issues with role=alert in SRs.
|
|
576
|
+
role: 'status',
|
|
577
|
+
'aria-live': type === 'foreground' ? 'assertive' : 'polite',
|
|
578
|
+
children: announceTextContent,
|
|
579
|
+
})
|
|
580
|
+
: null,
|
|
581
|
+
createElement(ToastInteractiveProvider, {
|
|
582
|
+
key: 'toast',
|
|
583
|
+
scope: __scopeToast,
|
|
584
|
+
onClose: handleClose,
|
|
585
|
+
children: createPortal(
|
|
586
|
+
createElement(Collection.ItemSlot, {
|
|
587
|
+
scope: __scopeToast,
|
|
588
|
+
children: createElement(DismissableLayer, {
|
|
589
|
+
asChild: true,
|
|
590
|
+
onEscapeKeyDown: composeEventHandlers(onEscapeKeyDown, () => {
|
|
591
|
+
if (!context.isFocusedToastEscapeKeyDownRef.current) handleClose();
|
|
592
|
+
context.isFocusedToastEscapeKeyDownRef.current = false;
|
|
593
|
+
}),
|
|
594
|
+
children: createElement(Primitive.li, {
|
|
595
|
+
// Ensure toasts are announced as status list or status when focused
|
|
596
|
+
tabIndex: 0,
|
|
597
|
+
'data-state': open ? 'open' : 'closed',
|
|
598
|
+
'data-swipe-direction': context.swipeDirection,
|
|
599
|
+
...toastProps,
|
|
600
|
+
ref: composedRefs,
|
|
601
|
+
style: { userSelect: 'none', touchAction: 'none', ...props?.style },
|
|
602
|
+
onKeyDown: composeEventHandlers(toastProps.onKeyDown, (event: KeyboardEvent) => {
|
|
603
|
+
if (event.key !== 'Escape') return;
|
|
604
|
+
// octane: events are native — React's `event.nativeEvent` is `event`.
|
|
605
|
+
onEscapeKeyDown?.(event);
|
|
606
|
+
if (!event.defaultPrevented) {
|
|
607
|
+
context.isFocusedToastEscapeKeyDownRef.current = true;
|
|
608
|
+
handleClose();
|
|
609
|
+
}
|
|
610
|
+
}),
|
|
611
|
+
onPointerDown: composeEventHandlers(
|
|
612
|
+
toastProps.onPointerDown,
|
|
613
|
+
(event: PointerEvent) => {
|
|
614
|
+
if (event.button !== 0) return;
|
|
615
|
+
pointerStartRef.current = { x: event.clientX, y: event.clientY };
|
|
616
|
+
},
|
|
617
|
+
),
|
|
618
|
+
onPointerMove: composeEventHandlers(
|
|
619
|
+
toastProps.onPointerMove,
|
|
620
|
+
(event: PointerEvent) => {
|
|
621
|
+
if (!pointerStartRef.current) return;
|
|
622
|
+
const x = event.clientX - pointerStartRef.current.x;
|
|
623
|
+
const y = event.clientY - pointerStartRef.current.y;
|
|
624
|
+
const hasSwipeMoveStarted = Boolean(swipeDeltaRef.current);
|
|
625
|
+
const isHorizontalSwipe = ['left', 'right'].includes(context.swipeDirection);
|
|
626
|
+
const clamp = ['left', 'up'].includes(context.swipeDirection)
|
|
627
|
+
? Math.min
|
|
628
|
+
: Math.max;
|
|
629
|
+
const clampedX = isHorizontalSwipe ? clamp(0, x) : 0;
|
|
630
|
+
const clampedY = !isHorizontalSwipe ? clamp(0, y) : 0;
|
|
631
|
+
const moveStartBuffer = event.pointerType === 'touch' ? 10 : 2;
|
|
632
|
+
const delta = { x: clampedX, y: clampedY };
|
|
633
|
+
const eventDetail = { originalEvent: event, delta };
|
|
634
|
+
if (hasSwipeMoveStarted) {
|
|
635
|
+
swipeDeltaRef.current = delta;
|
|
636
|
+
handleAndDispatchCustomEvent(TOAST_SWIPE_MOVE, onSwipeMove, eventDetail, {
|
|
637
|
+
discrete: false,
|
|
638
|
+
});
|
|
639
|
+
} else if (isDeltaInDirection(delta, context.swipeDirection, moveStartBuffer)) {
|
|
640
|
+
swipeDeltaRef.current = delta;
|
|
641
|
+
handleAndDispatchCustomEvent(TOAST_SWIPE_START, onSwipeStart, eventDetail, {
|
|
642
|
+
discrete: false,
|
|
643
|
+
});
|
|
644
|
+
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
|
645
|
+
} else if (Math.abs(x) > moveStartBuffer || Math.abs(y) > moveStartBuffer) {
|
|
646
|
+
// User is swiping in wrong direction so we disable swipe gesture
|
|
647
|
+
// for the current pointer down interaction
|
|
648
|
+
pointerStartRef.current = null;
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
),
|
|
652
|
+
onPointerUp: composeEventHandlers(toastProps.onPointerUp, (event: PointerEvent) => {
|
|
653
|
+
const delta = swipeDeltaRef.current;
|
|
654
|
+
const target = event.target as HTMLElement;
|
|
655
|
+
if (target.hasPointerCapture(event.pointerId)) {
|
|
656
|
+
target.releasePointerCapture(event.pointerId);
|
|
657
|
+
}
|
|
658
|
+
swipeDeltaRef.current = null;
|
|
659
|
+
pointerStartRef.current = null;
|
|
660
|
+
if (delta) {
|
|
661
|
+
const toast = event.currentTarget as HTMLElement;
|
|
662
|
+
const eventDetail = { originalEvent: event, delta };
|
|
663
|
+
if (isDeltaInDirection(delta, context.swipeDirection, context.swipeThreshold)) {
|
|
664
|
+
handleAndDispatchCustomEvent(TOAST_SWIPE_END, onSwipeEnd, eventDetail, {
|
|
665
|
+
discrete: true,
|
|
666
|
+
});
|
|
667
|
+
} else {
|
|
668
|
+
handleAndDispatchCustomEvent(TOAST_SWIPE_CANCEL, onSwipeCancel, eventDetail, {
|
|
669
|
+
discrete: true,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
// Prevent click event from triggering on items within the toast when
|
|
673
|
+
// pointer up is part of a swipe gesture
|
|
674
|
+
toast.addEventListener('click', (event) => event.preventDefault(), {
|
|
675
|
+
once: true,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}),
|
|
679
|
+
}),
|
|
680
|
+
}),
|
|
681
|
+
}),
|
|
682
|
+
context.viewport,
|
|
683
|
+
),
|
|
684
|
+
}),
|
|
685
|
+
];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/* -----------------------------------------------------------------------------------------------*/
|
|
689
|
+
|
|
690
|
+
function ToastAnnounce(props: any): any {
|
|
691
|
+
const slot = S('Toast.Announce');
|
|
692
|
+
const { __scopeToast, children, ...announceProps } = props ?? {};
|
|
693
|
+
const context = useToastProviderContext(TOAST_NAME, __scopeToast);
|
|
694
|
+
const [renderAnnounceText, setRenderAnnounceText] = useState(false, subSlot(slot, 'render'));
|
|
695
|
+
const [isAnnounced, setIsAnnounced] = useState(false, subSlot(slot, 'announced'));
|
|
696
|
+
|
|
697
|
+
// render text content in the next frame to ensure toast is announced in NVDA
|
|
698
|
+
useNextFrame(() => setRenderAnnounceText(true), subSlot(slot, 'frame'));
|
|
699
|
+
|
|
700
|
+
// cleanup after announcing
|
|
701
|
+
useEffect(
|
|
702
|
+
() => {
|
|
703
|
+
const timer = window.setTimeout(() => setIsAnnounced(true), 1000);
|
|
704
|
+
return () => window.clearTimeout(timer);
|
|
705
|
+
},
|
|
706
|
+
[],
|
|
707
|
+
subSlot(slot, 'e:cleanup'),
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
return isAnnounced
|
|
711
|
+
? null
|
|
712
|
+
: createElement(PortalPrimitive, {
|
|
713
|
+
asChild: true,
|
|
714
|
+
container: context.announcerContainer || undefined,
|
|
715
|
+
children: createElement(VisuallyHidden, {
|
|
716
|
+
...announceProps,
|
|
717
|
+
children: renderAnnounceText
|
|
718
|
+
? [context.label, ' ', ...(Array.isArray(children) ? children : [children])]
|
|
719
|
+
: null,
|
|
720
|
+
}),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* -------------------------------------------------------------------------------------------------
|
|
725
|
+
* ToastTitle
|
|
726
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
727
|
+
|
|
728
|
+
export function Title(props: any): any {
|
|
729
|
+
const { __scopeToast, ...titleProps } = props ?? {};
|
|
730
|
+
return createElement(Primitive.div, { ...titleProps });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/* -------------------------------------------------------------------------------------------------
|
|
734
|
+
* ToastDescription
|
|
735
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
736
|
+
|
|
737
|
+
export function Description(props: any): any {
|
|
738
|
+
const { __scopeToast, ...descriptionProps } = props ?? {};
|
|
739
|
+
return createElement(Primitive.div, { ...descriptionProps });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/* -------------------------------------------------------------------------------------------------
|
|
743
|
+
* ToastAction
|
|
744
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
745
|
+
|
|
746
|
+
export function Action(props: any): any {
|
|
747
|
+
const { altText, ref: forwardedRef, ...actionProps } = props ?? {};
|
|
748
|
+
|
|
749
|
+
if (!altText || !String(altText).trim()) {
|
|
750
|
+
// (dev-only console.error intentionally not ported; the functional outcome —
|
|
751
|
+
// an Action without a valid `altText` does not render — is preserved)
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return createElement(ToastAnnounceExclude, {
|
|
756
|
+
altText,
|
|
757
|
+
asChild: true,
|
|
758
|
+
children: createElement(Close, { ...actionProps, ref: forwardedRef }),
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* -------------------------------------------------------------------------------------------------
|
|
763
|
+
* ToastClose
|
|
764
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
765
|
+
|
|
766
|
+
const CLOSE_NAME = 'ToastClose';
|
|
767
|
+
|
|
768
|
+
export function Close(props: any): any {
|
|
769
|
+
const { __scopeToast, ref: forwardedRef, ...closeProps } = props ?? {};
|
|
770
|
+
const interactiveContext = useToastInteractiveContext(CLOSE_NAME, __scopeToast) as any;
|
|
771
|
+
|
|
772
|
+
return createElement(ToastAnnounceExclude, {
|
|
773
|
+
asChild: true,
|
|
774
|
+
children: createElement(Primitive.button, {
|
|
775
|
+
type: 'button',
|
|
776
|
+
...closeProps,
|
|
777
|
+
ref: forwardedRef,
|
|
778
|
+
onClick: composeEventHandlers(props?.onClick, interactiveContext.onClose),
|
|
779
|
+
}),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* ---------------------------------------------------------------------------------------------- */
|
|
784
|
+
|
|
785
|
+
function ToastAnnounceExclude(props: any): any {
|
|
786
|
+
const { __scopeToast, altText, ref: forwardedRef, ...announceExcludeProps } = props ?? {};
|
|
787
|
+
|
|
788
|
+
return createElement(Primitive.div, {
|
|
789
|
+
'data-radix-toast-announce-exclude': '',
|
|
790
|
+
'data-radix-toast-announce-alt': altText || undefined,
|
|
791
|
+
...announceExcludeProps,
|
|
792
|
+
ref: forwardedRef,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function getAnnounceTextContent(container: HTMLElement): string[] {
|
|
797
|
+
const textContent: string[] = [];
|
|
798
|
+
const childNodes = Array.from(container.childNodes);
|
|
799
|
+
|
|
800
|
+
childNodes.forEach((node) => {
|
|
801
|
+
if (node.nodeType === node.TEXT_NODE && node.textContent) textContent.push(node.textContent);
|
|
802
|
+
if (isHTMLElement(node)) {
|
|
803
|
+
const isHidden = node.ariaHidden || node.hidden || node.style.display === 'none';
|
|
804
|
+
const isExcluded = node.dataset.radixToastAnnounceExclude === '';
|
|
805
|
+
|
|
806
|
+
if (!isHidden) {
|
|
807
|
+
if (isExcluded) {
|
|
808
|
+
const altText = node.dataset.radixToastAnnounceAlt;
|
|
809
|
+
if (altText) textContent.push(altText);
|
|
810
|
+
} else {
|
|
811
|
+
textContent.push(...getAnnounceTextContent(node));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// We return a collection of text rather than a single concatenated string.
|
|
818
|
+
// This allows SR VO to naturally pause break between nodes while announcing.
|
|
819
|
+
return textContent;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/* ---------------------------------------------------------------------------------------------- */
|
|
823
|
+
|
|
824
|
+
function handleAndDispatchCustomEvent(
|
|
825
|
+
name: string,
|
|
826
|
+
handler: ((event: any) => void) | undefined,
|
|
827
|
+
detail: { originalEvent: Event } & Record<string, any>,
|
|
828
|
+
{ discrete }: { discrete: boolean },
|
|
829
|
+
): void {
|
|
830
|
+
const currentTarget = detail.originalEvent.currentTarget as HTMLElement;
|
|
831
|
+
const event = new CustomEvent(name, { bubbles: true, cancelable: true, detail });
|
|
832
|
+
if (handler) currentTarget.addEventListener(name, handler as EventListener, { once: true });
|
|
833
|
+
|
|
834
|
+
if (discrete) {
|
|
835
|
+
dispatchDiscreteCustomEvent(currentTarget, event);
|
|
836
|
+
} else {
|
|
837
|
+
currentTarget.dispatchEvent(event);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const isDeltaInDirection = (
|
|
842
|
+
delta: { x: number; y: number },
|
|
843
|
+
direction: SwipeDirection,
|
|
844
|
+
threshold = 0,
|
|
845
|
+
): boolean => {
|
|
846
|
+
const deltaX = Math.abs(delta.x);
|
|
847
|
+
const deltaY = Math.abs(delta.y);
|
|
848
|
+
const isDeltaX = deltaX > deltaY;
|
|
849
|
+
if (direction === 'left' || direction === 'right') {
|
|
850
|
+
return isDeltaX && deltaX > threshold;
|
|
851
|
+
} else {
|
|
852
|
+
return !isDeltaX && deltaY > threshold;
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
function useNextFrame(callback: () => void, slot: symbol | undefined): void {
|
|
857
|
+
const fn = useCallbackRef(callback, subSlot(slot, 'cb'));
|
|
858
|
+
useLayoutEffect(
|
|
859
|
+
() => {
|
|
860
|
+
let raf1 = 0;
|
|
861
|
+
let raf2 = 0;
|
|
862
|
+
raf1 = window.requestAnimationFrame(() => (raf2 = window.requestAnimationFrame(fn)));
|
|
863
|
+
return () => {
|
|
864
|
+
window.cancelAnimationFrame(raf1);
|
|
865
|
+
window.cancelAnimationFrame(raf2);
|
|
866
|
+
};
|
|
867
|
+
},
|
|
868
|
+
[fn],
|
|
869
|
+
subSlot(slot, 'e'),
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function isHTMLElement(node: any): node is HTMLElement {
|
|
874
|
+
return node.nodeType === node.ELEMENT_NODE;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Returns a list of potential tabbable candidates.
|
|
879
|
+
*
|
|
880
|
+
* NOTE: This is only a close approximation. For example it doesn't take into account cases like when
|
|
881
|
+
* elements are not visible. This cannot be worked out easily by just reading a property, but rather
|
|
882
|
+
* necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
|
|
883
|
+
*
|
|
884
|
+
* See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
|
|
885
|
+
* Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
|
|
886
|
+
*/
|
|
887
|
+
function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
|
|
888
|
+
const nodes: HTMLElement[] = [];
|
|
889
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
|
890
|
+
acceptNode: (node: any) => {
|
|
891
|
+
const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
|
|
892
|
+
if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
|
|
893
|
+
// `.tabIndex` is not the same as the `tabindex` attribute. It works on the
|
|
894
|
+
// runtime's understanding of tabbability, so this automatically accounts
|
|
895
|
+
// for any kind of element that could be tabbed to.
|
|
896
|
+
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
|
|
900
|
+
// we do not take into account the order of nodes with positive `tabIndex` as it
|
|
901
|
+
// hinders accessibility to have tab order different from visual order.
|
|
902
|
+
return nodes;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function focusFirst(candidates: HTMLElement[]): boolean {
|
|
906
|
+
const previouslyFocusedElement = document.activeElement;
|
|
907
|
+
return candidates.some((candidate) => {
|
|
908
|
+
// if focus is already where we want to go, we don't want to keep going through the candidates
|
|
909
|
+
if (candidate === previouslyFocusedElement) return true;
|
|
910
|
+
candidate.focus();
|
|
911
|
+
return document.activeElement !== previouslyFocusedElement;
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export {
|
|
916
|
+
Provider as ToastProvider,
|
|
917
|
+
Viewport as ToastViewport,
|
|
918
|
+
Root as Toast,
|
|
919
|
+
Title as ToastTitle,
|
|
920
|
+
Description as ToastDescription,
|
|
921
|
+
Action as ToastAction,
|
|
922
|
+
Close as ToastClose,
|
|
923
|
+
};
|