@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. 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
+ };