@octanejs/base-ui 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/index.ts +1 -0
- package/src/toast.ts +2462 -0
- package/src/utils/constants.ts +10 -0
- package/src/utils/generateId.ts +10 -0
- package/src/utils/useOnMount.ts +17 -0
package/src/toast.ts
ADDED
|
@@ -0,0 +1,2462 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/toast/ (v1.6.0) — Phase 3g.
|
|
2
|
+
//
|
|
3
|
+
// Toast is the one overlay family that is NOT driven by a trigger and a floating store. Toasts are
|
|
4
|
+
// created imperatively — from `useToastManager().add()` inside a component, or from a
|
|
5
|
+
// `createToastManager()` handle outside React entirely — and live in a `ToastStore` that owns the
|
|
6
|
+
// list, the per-toast auto-dismiss timers, and the pause/resume rules (hover, focus, window blur,
|
|
7
|
+
// touch). `Toast.Viewport` is a live region that renders them; `Toast.Root` is one toast, including
|
|
8
|
+
// its own pointer-drag swipe-to-dismiss.
|
|
9
|
+
//
|
|
10
|
+
// SCOPE NOTE — the migration plan said this phase also lands `useSwipeDismiss` (1,208 loc) because
|
|
11
|
+
// "it is shared with Drawer". That is wrong for 1.6.0: Toast implements its swipe inline in
|
|
12
|
+
// `ToastRoot` and imports only two pure geometry helpers (`getDisplacement`, `getElementTransform`)
|
|
13
|
+
// from that file, which are inlined here. The hook itself has exactly two consumers, both Drawer
|
|
14
|
+
// (`DrawerViewport`, `DrawerSwipeArea`), so it moves to Phase 6 where it is actually used.
|
|
15
|
+
//
|
|
16
|
+
// octane adaptations:
|
|
17
|
+
// - components are `createElement`, not JSX; `forwardRef` → ref-as-prop; each component takes its
|
|
18
|
+
// per-call-site slot from `S('…')` and derives every composed hook's slot via `subSlot`;
|
|
19
|
+
// - `ReactDOM.flushSync` → octane `flushSync`;
|
|
20
|
+
// - native, delegated events, so `event.nativeEvent` collapses to `event`;
|
|
21
|
+
// - `React.createContext`/`useContext` → octane's; `useIsoLayoutEffect` → `useLayoutEffect`;
|
|
22
|
+
// - `useId` → `useBaseUiId` for element ids (Title/Description), matching the rest of the binding;
|
|
23
|
+
// the toast IDS themselves come from `generateId`, because they are minted outside render;
|
|
24
|
+
// - this package carries NO `process.env.NODE_ENV` branches.
|
|
25
|
+
import {
|
|
26
|
+
createContext,
|
|
27
|
+
createElement,
|
|
28
|
+
Fragment,
|
|
29
|
+
useContext,
|
|
30
|
+
useMemo,
|
|
31
|
+
useRef,
|
|
32
|
+
useState,
|
|
33
|
+
useEffect,
|
|
34
|
+
useLayoutEffect,
|
|
35
|
+
useId,
|
|
36
|
+
flushSync,
|
|
37
|
+
} from 'octane';
|
|
38
|
+
|
|
39
|
+
import { S, subSlot } from './internal';
|
|
40
|
+
import { useRenderElement } from './utils/useRenderElement';
|
|
41
|
+
import { usePositioner } from './utils/usePositioner';
|
|
42
|
+
import { useButton } from './utils/useButton';
|
|
43
|
+
import { useStableCallback } from './utils/useStableCallback';
|
|
44
|
+
import { useRefWithInit } from './utils/useRefWithInit';
|
|
45
|
+
import { useOnMount } from './utils/useOnMount';
|
|
46
|
+
import { useTimeout, Timeout } from './utils/useTimeout';
|
|
47
|
+
import { useOpenChangeComplete } from './utils/useOpenChangeComplete';
|
|
48
|
+
import { generateId } from './utils/generateId';
|
|
49
|
+
import { ownerDocument, ownerWindow } from './utils/owner';
|
|
50
|
+
import { addEventListener } from './utils/addEventListener';
|
|
51
|
+
import { mergeCleanups } from './utils/mergeCleanups';
|
|
52
|
+
import { visuallyHidden } from './utils/visuallyHidden';
|
|
53
|
+
import { inertValue } from './utils/inertValue';
|
|
54
|
+
import { EMPTY_OBJECT } from './utils/empty';
|
|
55
|
+
import { NOOP } from './utils/noop';
|
|
56
|
+
import { isElement } from './utils/dom';
|
|
57
|
+
import { ReactStore } from './utils/store/ReactStore';
|
|
58
|
+
import { createSelector } from './utils/store/createSelector';
|
|
59
|
+
import { transitionStatusMapping } from './utils/useTransitionStatus';
|
|
60
|
+
import type { TransitionStatus } from './utils/useTransitionStatus';
|
|
61
|
+
import type { StateAttributesMapping } from './utils/getStateAttributesProps';
|
|
62
|
+
import { matchesFocusVisible as isFocusVisible } from './utils/matchesFocusVisible';
|
|
63
|
+
import { activeElement, contains } from './utils/floating/element';
|
|
64
|
+
import { getTarget } from './utils/composite/list-utils';
|
|
65
|
+
import { FocusGuard } from './utils/floating/FocusGuard';
|
|
66
|
+
import { FloatingPortalLite } from './utils/FloatingPortalLite';
|
|
67
|
+
import { useFloatingRootContext } from './utils/floating/useFloatingRootContext';
|
|
68
|
+
import {
|
|
69
|
+
useAnchorPositioning,
|
|
70
|
+
type Align,
|
|
71
|
+
type Side,
|
|
72
|
+
type UseAnchorPositioningSharedParameters,
|
|
73
|
+
} from './utils/useAnchorPositioning';
|
|
74
|
+
import {
|
|
75
|
+
POPUP_COLLISION_AVOIDANCE,
|
|
76
|
+
BASE_UI_SWIPE_IGNORE_SELECTOR,
|
|
77
|
+
LEGACY_SWIPE_IGNORE_SELECTOR,
|
|
78
|
+
} from './utils/constants';
|
|
79
|
+
|
|
80
|
+
type HTMLProps = Record<string, any>;
|
|
81
|
+
|
|
82
|
+
export type SwipeDirection = 'up' | 'down' | 'left' | 'right';
|
|
83
|
+
|
|
84
|
+
// --- CSS variables ------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
export const ToastRootCssVars = {
|
|
87
|
+
/** The index of the toast in the list. */
|
|
88
|
+
index: '--toast-index',
|
|
89
|
+
/** The vertical pixel offset of the toast in the list when expanded. */
|
|
90
|
+
offsetY: '--toast-offset-y',
|
|
91
|
+
/** The measured natural height of the toast in pixels. */
|
|
92
|
+
height: '--toast-height',
|
|
93
|
+
/** The horizontal swipe movement of the toast. */
|
|
94
|
+
swipeMovementX: '--toast-swipe-movement-x',
|
|
95
|
+
/** The vertical swipe movement of the toast. */
|
|
96
|
+
swipeMovementY: '--toast-swipe-movement-y',
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
export const ToastViewportCssVars = {
|
|
100
|
+
/** The height of the frontmost toast. */
|
|
101
|
+
frontmostHeight: '--toast-frontmost-height',
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
export const ToastPositionerCssVars = {
|
|
105
|
+
availableWidth: '--available-width',
|
|
106
|
+
availableHeight: '--available-height',
|
|
107
|
+
anchorWidth: '--anchor-width',
|
|
108
|
+
anchorHeight: '--anchor-height',
|
|
109
|
+
transformOrigin: '--transform-origin',
|
|
110
|
+
} as const;
|
|
111
|
+
|
|
112
|
+
// --- Toast object -------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export interface ToastObject<Data extends object = any> {
|
|
115
|
+
/** The unique identifier for the toast. */
|
|
116
|
+
id: string;
|
|
117
|
+
/** The ref for the toast. */
|
|
118
|
+
ref?: { current: HTMLElement | null } | undefined;
|
|
119
|
+
/** The title of the toast. */
|
|
120
|
+
title?: any;
|
|
121
|
+
/**
|
|
122
|
+
* The type of the toast. Used to conditionally style the toast, including conditionally
|
|
123
|
+
* rendering elements based on the type.
|
|
124
|
+
*/
|
|
125
|
+
type?: string | undefined;
|
|
126
|
+
/** The description of the toast. */
|
|
127
|
+
description?: any;
|
|
128
|
+
/**
|
|
129
|
+
* The amount of time (in ms) before the toast is auto dismissed. `0` prevents auto-dismissal.
|
|
130
|
+
* @default 5000
|
|
131
|
+
*/
|
|
132
|
+
timeout?: number | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* The priority of the toast. `low` announces politely, `high` announces urgently.
|
|
135
|
+
* @default 'low'
|
|
136
|
+
*/
|
|
137
|
+
priority?: 'low' | 'high' | undefined;
|
|
138
|
+
/** The transition status of the toast. */
|
|
139
|
+
transitionStatus?: 'starting' | 'ending' | undefined;
|
|
140
|
+
/** A counter that increments whenever the toast is updated or upserted. */
|
|
141
|
+
updateKey?: number | undefined;
|
|
142
|
+
/** Whether the toast was limited because the toast limit was exceeded. */
|
|
143
|
+
limited?: boolean | undefined;
|
|
144
|
+
/** The height of the toast. */
|
|
145
|
+
height?: number | undefined;
|
|
146
|
+
/** Called when the toast is closed. */
|
|
147
|
+
onClose?: (() => void) | undefined;
|
|
148
|
+
/** Called when the toast is removed from the list after any closing animations complete. */
|
|
149
|
+
onRemove?: (() => void) | undefined;
|
|
150
|
+
/** The props for the action button. */
|
|
151
|
+
actionProps?: HTMLProps | undefined;
|
|
152
|
+
/** Props forwarded to the toast positioner element when rendering anchored toasts. */
|
|
153
|
+
positionerProps?: HTMLProps | undefined;
|
|
154
|
+
/** Custom data for the toast. */
|
|
155
|
+
data?: Data | undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ToastManagerAddOptions<Data extends object = any> extends Omit<
|
|
159
|
+
ToastObject<Data>,
|
|
160
|
+
'id' | 'height' | 'ref' | 'limited' | 'updateKey'
|
|
161
|
+
> {
|
|
162
|
+
/**
|
|
163
|
+
* The unique identifier for the toast. Adding a toast with an existing ID updates it in place
|
|
164
|
+
* and refreshes its auto-dismiss timer.
|
|
165
|
+
*/
|
|
166
|
+
id?: string | undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ToastManagerUpdateOptions<Data extends object = any> extends Partial<
|
|
170
|
+
Omit<ToastObject<Data>, 'id' | 'ref' | 'height' | 'transitionStatus' | 'limited' | 'updateKey'>
|
|
171
|
+
> {}
|
|
172
|
+
|
|
173
|
+
export interface ToastManagerPromiseOptions<Value, Data extends object = any> {
|
|
174
|
+
loading: string | ToastManagerUpdateOptions<Data>;
|
|
175
|
+
success:
|
|
176
|
+
| string
|
|
177
|
+
| ToastManagerUpdateOptions<Data>
|
|
178
|
+
| ((result: Value) => string | ToastManagerUpdateOptions<Data>);
|
|
179
|
+
error:
|
|
180
|
+
| string
|
|
181
|
+
| ToastManagerUpdateOptions<Data>
|
|
182
|
+
| ((error: any) => string | ToastManagerUpdateOptions<Data>);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Ported from `toast/utils/resolvePromiseOptions.ts`. */
|
|
186
|
+
export function resolvePromiseOptions<T, Data extends object>(
|
|
187
|
+
options:
|
|
188
|
+
| string
|
|
189
|
+
| ToastManagerUpdateOptions<Data>
|
|
190
|
+
| ((result: T) => string | ToastManagerUpdateOptions<Data>),
|
|
191
|
+
result?: T,
|
|
192
|
+
): ToastManagerUpdateOptions<Data> {
|
|
193
|
+
if (typeof options === 'string') {
|
|
194
|
+
return { description: options };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (typeof options === 'function') {
|
|
198
|
+
const resolvedOptions = options(result as T);
|
|
199
|
+
return typeof resolvedOptions === 'string' ? { description: resolvedOptions } : resolvedOptions;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return options;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Store --------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
type ToastInternalUpdateOptions<Data extends object> = Partial<Omit<ToastObject<Data>, 'id'>>;
|
|
208
|
+
type UpdateToastBehavior = {
|
|
209
|
+
resetTimer?: boolean | undefined;
|
|
210
|
+
markUpdated?: boolean | undefined;
|
|
211
|
+
};
|
|
212
|
+
type RemoveToastBehavior = {
|
|
213
|
+
skipOnRemove?: boolean | undefined;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export type ToastState = {
|
|
217
|
+
toasts: ToastObject<any>[];
|
|
218
|
+
toastMetadata: Map<string, ToastMetadata>;
|
|
219
|
+
hovering: boolean;
|
|
220
|
+
focused: boolean;
|
|
221
|
+
timeout: number;
|
|
222
|
+
limit: number;
|
|
223
|
+
isWindowFocused: boolean;
|
|
224
|
+
viewport: HTMLElement | null;
|
|
225
|
+
prevFocusElement: HTMLElement | null;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
type ToastMetadata = {
|
|
229
|
+
value: ToastObject<any>;
|
|
230
|
+
domIndex: number;
|
|
231
|
+
visibleIndex: number;
|
|
232
|
+
offsetY: number;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
type InitialToastState = Omit<ToastState, 'toastMetadata'>;
|
|
236
|
+
|
|
237
|
+
function createToastMetadata(toasts: ToastObject<any>[]) {
|
|
238
|
+
const metadata = new Map<string, ToastMetadata>();
|
|
239
|
+
let visibleIndex = 0;
|
|
240
|
+
let offsetY = 0;
|
|
241
|
+
|
|
242
|
+
toasts.forEach((toast, toastIndex) => {
|
|
243
|
+
const isEnding = toast.transitionStatus === 'ending';
|
|
244
|
+
metadata.set(toast.id, {
|
|
245
|
+
value: toast,
|
|
246
|
+
domIndex: toastIndex,
|
|
247
|
+
visibleIndex: isEnding ? -1 : visibleIndex,
|
|
248
|
+
offsetY,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
offsetY += toast.height || 0;
|
|
252
|
+
|
|
253
|
+
if (!isEnding) {
|
|
254
|
+
visibleIndex += 1;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return metadata;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Marks the active (non-ending) toasts beyond `limit` as limited. Callers pass toasts in
|
|
262
|
+
// newest-first order, so the newest `limit` toasts stay visible and the rest are flagged. Returns
|
|
263
|
+
// the same toast reference when its `limited` flag is unchanged to avoid unnecessary re-renders.
|
|
264
|
+
function applyLimited(toasts: ToastObject<any>[], limit: number): ToastObject<any>[] {
|
|
265
|
+
let activeIndex = 0;
|
|
266
|
+
return toasts.map((toast) => {
|
|
267
|
+
if (toast.transitionStatus === 'ending') {
|
|
268
|
+
return toast;
|
|
269
|
+
}
|
|
270
|
+
const limited = activeIndex >= limit;
|
|
271
|
+
activeIndex += 1;
|
|
272
|
+
return toast.limited === limited ? toast : { ...toast, limited };
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const toastMetadataSelector = (state: ToastState) => state.toastMetadata;
|
|
277
|
+
|
|
278
|
+
export const toastSelectors = {
|
|
279
|
+
toasts: createSelector((state: ToastState) => state.toasts),
|
|
280
|
+
isEmpty: createSelector((state: ToastState) => state.toasts.length === 0),
|
|
281
|
+
toast: createSelector(
|
|
282
|
+
toastMetadataSelector,
|
|
283
|
+
(toastMetadata: Map<string, ToastMetadata>, id: string) => toastMetadata.get(id)?.value,
|
|
284
|
+
),
|
|
285
|
+
toastIndex: createSelector(
|
|
286
|
+
toastMetadataSelector,
|
|
287
|
+
(toastMetadata: Map<string, ToastMetadata>, id: string) =>
|
|
288
|
+
toastMetadata.get(id)?.domIndex ?? -1,
|
|
289
|
+
),
|
|
290
|
+
toastOffsetY: createSelector(
|
|
291
|
+
toastMetadataSelector,
|
|
292
|
+
(toastMetadata: Map<string, ToastMetadata>, id: string) => toastMetadata.get(id)?.offsetY ?? 0,
|
|
293
|
+
),
|
|
294
|
+
toastVisibleIndex: createSelector(
|
|
295
|
+
toastMetadataSelector,
|
|
296
|
+
(toastMetadata: Map<string, ToastMetadata>, id: string) =>
|
|
297
|
+
toastMetadata.get(id)?.visibleIndex ?? -1,
|
|
298
|
+
),
|
|
299
|
+
hovering: createSelector((state: ToastState) => state.hovering),
|
|
300
|
+
focused: createSelector((state: ToastState) => state.focused),
|
|
301
|
+
expanded: createSelector((state: ToastState) => state.hovering || state.focused),
|
|
302
|
+
expandedOrOutOfFocus: createSelector(
|
|
303
|
+
(state: ToastState) => state.hovering || state.focused || !state.isWindowFocused,
|
|
304
|
+
),
|
|
305
|
+
prevFocusElement: createSelector((state: ToastState) => state.prevFocusElement),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
interface TimerInfo {
|
|
309
|
+
timeout?: Timeout | undefined;
|
|
310
|
+
start: number;
|
|
311
|
+
delay: number;
|
|
312
|
+
remaining: number;
|
|
313
|
+
callback: () => void;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export class ToastStore extends ReactStore<ToastState, {}, typeof toastSelectors> {
|
|
317
|
+
private timers = new Map<string, TimerInfo>();
|
|
318
|
+
|
|
319
|
+
private areTimersPaused = false;
|
|
320
|
+
|
|
321
|
+
constructor(initialState: InitialToastState) {
|
|
322
|
+
super(
|
|
323
|
+
{
|
|
324
|
+
...initialState,
|
|
325
|
+
toastMetadata: createToastMetadata(initialState.toasts),
|
|
326
|
+
},
|
|
327
|
+
{},
|
|
328
|
+
toastSelectors,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
setFocused(focused: boolean) {
|
|
333
|
+
this.set('focused', focused);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setHovering(hovering: boolean) {
|
|
337
|
+
this.set('hovering', hovering);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
setIsWindowFocused(isWindowFocused: boolean) {
|
|
341
|
+
this.set('isWindowFocused', isWindowFocused);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
setPrevFocusElement(prevFocusElement: HTMLElement | null) {
|
|
345
|
+
this.set('prevFocusElement', prevFocusElement);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
setViewport = (viewport: HTMLElement | null) => {
|
|
349
|
+
this.set('viewport', viewport);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
syncProviderProps(props: Pick<ToastState, 'timeout' | 'limit'>) {
|
|
353
|
+
const limitChanged = this.state.limit !== props.limit;
|
|
354
|
+
|
|
355
|
+
if (this.state.timeout === props.timeout && !limitChanged) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const updates: Partial<ToastState> = {
|
|
360
|
+
timeout: props.timeout,
|
|
361
|
+
limit: props.limit,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
if (limitChanged) {
|
|
365
|
+
const newToasts = applyLimited(this.state.toasts, props.limit);
|
|
366
|
+
updates.toasts = newToasts;
|
|
367
|
+
updates.toastMetadata = createToastMetadata(newToasts);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this.update(updates);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
disposeEffect = () => {
|
|
374
|
+
return () => {
|
|
375
|
+
this.timers.forEach((timer) => {
|
|
376
|
+
timer.timeout?.clear();
|
|
377
|
+
});
|
|
378
|
+
this.timers.clear();
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
removeToast(toastId: string, behavior: RemoveToastBehavior = {}) {
|
|
383
|
+
const index = toastSelectors.toastIndex(this.state, toastId);
|
|
384
|
+
if (index === -1) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const toast = this.state.toasts[index];
|
|
389
|
+
if (!behavior.skipOnRemove) {
|
|
390
|
+
toast?.onRemove?.();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const newToasts = [...this.state.toasts];
|
|
394
|
+
newToasts.splice(index, 1);
|
|
395
|
+
this.setToasts(newToasts);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
addToast = <Data extends object>(toast: ToastManagerAddOptions<Data>): string => {
|
|
399
|
+
const { timeout, limit } = this.state;
|
|
400
|
+
const id = toast.id || generateId('toast');
|
|
401
|
+
|
|
402
|
+
if (toast.id) {
|
|
403
|
+
const existingToast = toastSelectors.toast(this.state, toast.id);
|
|
404
|
+
|
|
405
|
+
if (existingToast) {
|
|
406
|
+
if (existingToast.transitionStatus === 'ending') {
|
|
407
|
+
this.removeToast(toast.id, { skipOnRemove: true });
|
|
408
|
+
} else {
|
|
409
|
+
const { id: ignoredId, transitionStatus: ignoredTransitionStatus, ...updates } = toast;
|
|
410
|
+
this.updateToastInternal(toast.id, updates, {
|
|
411
|
+
resetTimer: true,
|
|
412
|
+
markUpdated: true,
|
|
413
|
+
});
|
|
414
|
+
return toast.id;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const toastToAdd: ToastObject<Data> = {
|
|
420
|
+
...toast,
|
|
421
|
+
id,
|
|
422
|
+
updateKey: 0,
|
|
423
|
+
transitionStatus: 'starting',
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const updatedToasts = [toastToAdd, ...this.state.toasts];
|
|
427
|
+
this.setToasts(applyLimited(updatedToasts, limit));
|
|
428
|
+
|
|
429
|
+
const duration = toastToAdd.timeout ?? timeout;
|
|
430
|
+
if (toastToAdd.type !== 'loading' && duration > 0) {
|
|
431
|
+
this.scheduleTimer(id, duration, () => this.closeToast(id));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (toastSelectors.expandedOrOutOfFocus(this.state)) {
|
|
435
|
+
this.pauseTimers();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return id;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
updateToast = <Data extends object>(id: string, updates: ToastManagerUpdateOptions<Data>) => {
|
|
442
|
+
this.updateToastInternal(id, updates, { markUpdated: true });
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
updateToastInternal = <Data extends object>(
|
|
446
|
+
id: string,
|
|
447
|
+
updates: ToastInternalUpdateOptions<Data>,
|
|
448
|
+
behavior: UpdateToastBehavior = {},
|
|
449
|
+
) => {
|
|
450
|
+
const { timeout, toasts } = this.state;
|
|
451
|
+
const prevToast = toastSelectors.toast(this.state, id) ?? null;
|
|
452
|
+
if (!prevToast) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Ignore updates for toasts that are already closing. This prevents races where async
|
|
457
|
+
// updates (e.g. promise success/error) can block a dismissal from completing.
|
|
458
|
+
if (prevToast.transitionStatus === 'ending') {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const nextToast: ToastObject<Data> = {
|
|
463
|
+
...prevToast,
|
|
464
|
+
...updates,
|
|
465
|
+
...(behavior.markUpdated && {
|
|
466
|
+
updateKey: (prevToast.updateKey ?? 0) + 1,
|
|
467
|
+
}),
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
this.setToasts(toasts.map((toast) => (toast.id === id ? nextToast : toast)));
|
|
471
|
+
|
|
472
|
+
const nextTimeout = nextToast.timeout ?? timeout;
|
|
473
|
+
const prevTimeout = prevToast?.timeout ?? timeout;
|
|
474
|
+
|
|
475
|
+
const timeoutUpdated = Object.hasOwn(updates, 'timeout');
|
|
476
|
+
|
|
477
|
+
const shouldHaveTimer =
|
|
478
|
+
nextToast.transitionStatus !== 'ending' && nextToast.type !== 'loading' && nextTimeout > 0;
|
|
479
|
+
|
|
480
|
+
const hasTimer = this.timers.has(id);
|
|
481
|
+
const timeoutChanged = prevTimeout !== nextTimeout;
|
|
482
|
+
const wasLoading = prevToast?.type === 'loading';
|
|
483
|
+
|
|
484
|
+
if (!shouldHaveTimer && hasTimer) {
|
|
485
|
+
this.clearTimer(id);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Schedule or reschedule timer if needed
|
|
490
|
+
if (
|
|
491
|
+
shouldHaveTimer &&
|
|
492
|
+
(!hasTimer || timeoutChanged || timeoutUpdated || wasLoading || behavior.resetTimer)
|
|
493
|
+
) {
|
|
494
|
+
this.clearTimer(id);
|
|
495
|
+
|
|
496
|
+
this.scheduleTimer(id, nextTimeout, () => this.closeToast(id));
|
|
497
|
+
|
|
498
|
+
if (toastSelectors.expandedOrOutOfFocus(this.state)) {
|
|
499
|
+
this.pauseTimers();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
closeToast = (toastId?: string) => {
|
|
505
|
+
const closeAll = toastId === undefined;
|
|
506
|
+
const { limit, toasts } = this.state;
|
|
507
|
+
let toastsToClose: ToastObject<any>[];
|
|
508
|
+
|
|
509
|
+
if (closeAll) {
|
|
510
|
+
toastsToClose = toasts;
|
|
511
|
+
this.clearTimers();
|
|
512
|
+
} else {
|
|
513
|
+
const toast = toastSelectors.toast(this.state, toastId);
|
|
514
|
+
if (!toast) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
toastsToClose = [toast];
|
|
518
|
+
this.clearTimer(toastId);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const endingToasts = toasts.map((item) =>
|
|
522
|
+
closeAll || item.id === toastId
|
|
523
|
+
? { ...item, transitionStatus: 'ending' as const, height: 0 }
|
|
524
|
+
: item,
|
|
525
|
+
);
|
|
526
|
+
const newToasts = applyLimited(endingToasts, limit);
|
|
527
|
+
|
|
528
|
+
const updates: Partial<ToastState> = {
|
|
529
|
+
toasts: newToasts,
|
|
530
|
+
toastMetadata: createToastMetadata(newToasts),
|
|
531
|
+
};
|
|
532
|
+
const hasActiveToasts = newToasts.some((toast) => toast.transitionStatus !== 'ending');
|
|
533
|
+
if (!hasActiveToasts) {
|
|
534
|
+
updates.hovering = false;
|
|
535
|
+
updates.focused = false;
|
|
536
|
+
}
|
|
537
|
+
this.update(updates);
|
|
538
|
+
|
|
539
|
+
toastsToClose.forEach((toast) => {
|
|
540
|
+
if (toast.transitionStatus !== 'ending') {
|
|
541
|
+
toast.onClose?.();
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
this.handleFocusManagement(toastId);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
promiseToast = <Value, Data extends object>(
|
|
549
|
+
promiseValue: Promise<Value>,
|
|
550
|
+
options: ToastManagerPromiseOptions<Value, Data>,
|
|
551
|
+
): Promise<Value> => {
|
|
552
|
+
// Create a loading toast (which does not auto-dismiss).
|
|
553
|
+
const loadingOptions = resolvePromiseOptions<never, Data>(options.loading);
|
|
554
|
+
const id = this.addToast({
|
|
555
|
+
...loadingOptions,
|
|
556
|
+
type: 'loading',
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const handledPromise = promiseValue
|
|
560
|
+
.then((result: Value) => {
|
|
561
|
+
const successOptions = resolvePromiseOptions<Value, Data>(options.success, result);
|
|
562
|
+
this.updateToast(id, {
|
|
563
|
+
...successOptions,
|
|
564
|
+
type: 'success',
|
|
565
|
+
timeout: successOptions.timeout,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
return result;
|
|
569
|
+
})
|
|
570
|
+
.catch((error) => {
|
|
571
|
+
const errorOptions = resolvePromiseOptions<any, Data>(options.error, error);
|
|
572
|
+
this.updateToast(id, {
|
|
573
|
+
...errorOptions,
|
|
574
|
+
type: 'error',
|
|
575
|
+
timeout: errorOptions.timeout,
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
return Promise.reject(error);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Private API used exclusively by `Manager` to hand the promise back to the manager after it
|
|
582
|
+
// is handled here.
|
|
583
|
+
if ({}.hasOwnProperty.call(options, 'setPromise')) {
|
|
584
|
+
(options as any).setPromise(handledPromise);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return handledPromise;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
pauseTimers() {
|
|
591
|
+
if (this.areTimersPaused) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
this.areTimersPaused = true;
|
|
595
|
+
this.timers.forEach((timer) => {
|
|
596
|
+
if (timer.timeout) {
|
|
597
|
+
timer.timeout.clear();
|
|
598
|
+
const elapsed = Date.now() - timer.start;
|
|
599
|
+
const remaining = timer.delay - elapsed;
|
|
600
|
+
timer.remaining = remaining > 0 ? remaining : 0;
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
resumeTimers() {
|
|
606
|
+
if (!this.areTimersPaused) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
this.areTimersPaused = false;
|
|
610
|
+
this.timers.forEach((timer, id) => {
|
|
611
|
+
timer.remaining = timer.remaining > 0 ? timer.remaining : timer.delay;
|
|
612
|
+
timer.timeout ??= Timeout.create();
|
|
613
|
+
timer.timeout.start(timer.remaining, () => {
|
|
614
|
+
this.handleTimerFired(id);
|
|
615
|
+
timer.callback();
|
|
616
|
+
});
|
|
617
|
+
timer.start = Date.now();
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
restoreFocusToPrevElement() {
|
|
622
|
+
this.state.prevFocusElement?.focus({ preventScroll: true });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
handleDocumentPointerDown = (event: Event) => {
|
|
626
|
+
if ((event as PointerEvent).pointerType !== 'touch') {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const target = getTarget(event) as Element | null;
|
|
631
|
+
if (contains(this.state.viewport, target)) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// This is explicit touch activity outside the viewport, so the paused interaction state
|
|
636
|
+
// should end even if the window focus state is unchanged.
|
|
637
|
+
this.resumeTimers();
|
|
638
|
+
this.update({ hovering: false, focused: false });
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
private scheduleTimer(id: string, delay: number, callback: () => void) {
|
|
642
|
+
const start = Date.now();
|
|
643
|
+
const shouldStartActive = !toastSelectors.expandedOrOutOfFocus(this.state);
|
|
644
|
+
const currentTimeout = shouldStartActive ? Timeout.create() : undefined;
|
|
645
|
+
|
|
646
|
+
currentTimeout?.start(delay, () => {
|
|
647
|
+
this.handleTimerFired(id);
|
|
648
|
+
callback();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
this.timers.set(id, {
|
|
652
|
+
timeout: currentTimeout,
|
|
653
|
+
start: shouldStartActive ? start : 0,
|
|
654
|
+
delay,
|
|
655
|
+
remaining: delay,
|
|
656
|
+
callback,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private clearTimers() {
|
|
661
|
+
this.timers.forEach((timer) => {
|
|
662
|
+
timer.timeout?.clear();
|
|
663
|
+
});
|
|
664
|
+
this.timers.clear();
|
|
665
|
+
this.areTimersPaused = false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private clearTimer(id: string) {
|
|
669
|
+
const timer = this.timers.get(id);
|
|
670
|
+
timer?.timeout?.clear();
|
|
671
|
+
this.timers.delete(id);
|
|
672
|
+
|
|
673
|
+
this.resetPausedStateIfNoTimersRemain();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private handleTimerFired(id: string) {
|
|
677
|
+
this.timers.delete(id);
|
|
678
|
+
this.resetPausedStateIfNoTimersRemain();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private resetPausedStateIfNoTimersRemain() {
|
|
682
|
+
if (this.timers.size === 0) {
|
|
683
|
+
// No timers remain to keep paused; clear the flag so a fresh toast's running timer can
|
|
684
|
+
// be paused again on hover/focus.
|
|
685
|
+
this.areTimersPaused = false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private setToasts(newToasts: ToastObject<any>[]) {
|
|
690
|
+
const updates: Partial<ToastState> = {
|
|
691
|
+
toasts: newToasts,
|
|
692
|
+
toastMetadata: createToastMetadata(newToasts),
|
|
693
|
+
};
|
|
694
|
+
if (newToasts.length === 0) {
|
|
695
|
+
updates.hovering = false;
|
|
696
|
+
updates.focused = false;
|
|
697
|
+
}
|
|
698
|
+
this.update(updates);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private handleFocusManagement(toastId: string | undefined) {
|
|
702
|
+
const activeEl = activeElement(ownerDocument(this.state.viewport));
|
|
703
|
+
if (
|
|
704
|
+
!this.state.viewport ||
|
|
705
|
+
!contains(this.state.viewport, activeEl) ||
|
|
706
|
+
!isFocusVisible(activeEl)
|
|
707
|
+
) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (toastId === undefined) {
|
|
712
|
+
this.restoreFocusToPrevElement();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const toasts = toastSelectors.toasts(this.state);
|
|
717
|
+
const currentIndex = toastSelectors.toastIndex(this.state, toastId);
|
|
718
|
+
let nextToast: ToastObject<any> | null = null;
|
|
719
|
+
|
|
720
|
+
// Try to find the next toast that isn't animating out
|
|
721
|
+
let index = currentIndex + 1;
|
|
722
|
+
while (index < toasts.length) {
|
|
723
|
+
if (toasts[index].transitionStatus !== 'ending') {
|
|
724
|
+
nextToast = toasts[index];
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
index += 1;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Go backwards if no next toast is found
|
|
731
|
+
if (!nextToast) {
|
|
732
|
+
index = currentIndex - 1;
|
|
733
|
+
while (index >= 0) {
|
|
734
|
+
if (toasts[index].transitionStatus !== 'ending') {
|
|
735
|
+
nextToast = toasts[index];
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
index -= 1;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (nextToast) {
|
|
743
|
+
nextToast.ref?.current?.focus();
|
|
744
|
+
} else {
|
|
745
|
+
this.restoreFocusToPrevElement();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// --- Manager (outside-React handle) -------------------------------------------
|
|
751
|
+
|
|
752
|
+
export interface ToastManagerEvent {
|
|
753
|
+
action: 'add' | 'close' | 'update' | 'promise';
|
|
754
|
+
options: any;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export interface ToastManager<Data extends object = any> {
|
|
758
|
+
' subscribe': (listener: (data: ToastManagerEvent) => void) => () => void;
|
|
759
|
+
add: <T extends Data = Data>(options: ToastManagerAddOptions<T>) => string;
|
|
760
|
+
close: (id?: string) => void;
|
|
761
|
+
update: <T extends Data = Data>(id: string, updates: ToastManagerUpdateOptions<T>) => void;
|
|
762
|
+
promise: <Value, T extends Data = Data>(
|
|
763
|
+
promiseValue: Promise<Value>,
|
|
764
|
+
options: ToastManagerPromiseOptions<Value, T>,
|
|
765
|
+
) => Promise<Value>;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Creates a new toast manager, for driving toasts from outside a component. */
|
|
769
|
+
export function createToastManager<Data extends object = any>(): ToastManager<Data> {
|
|
770
|
+
const listeners = new Set<(data: ToastManagerEvent) => void>();
|
|
771
|
+
|
|
772
|
+
function emit(data: ToastManagerEvent) {
|
|
773
|
+
listeners.forEach((listener) => listener(data));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
// Private aside from ToastProvider needing to access it.
|
|
778
|
+
' subscribe': function subscribe(listener: (data: ToastManagerEvent) => void) {
|
|
779
|
+
listeners.add(listener);
|
|
780
|
+
return () => {
|
|
781
|
+
listeners.delete(listener);
|
|
782
|
+
};
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
add<T extends Data = Data>(options: ToastManagerAddOptions<T>): string {
|
|
786
|
+
const id = options.id || generateId('toast');
|
|
787
|
+
const toastToAdd: ToastObject<T> = {
|
|
788
|
+
...options,
|
|
789
|
+
id,
|
|
790
|
+
transitionStatus: 'starting',
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
emit({ action: 'add', options: toastToAdd });
|
|
794
|
+
|
|
795
|
+
return id;
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
close(id?: string): void {
|
|
799
|
+
emit({ action: 'close', options: { id } });
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
update<T extends Data = Data>(id: string, updates: ToastManagerUpdateOptions<T>): void {
|
|
803
|
+
emit({ action: 'update', options: { ...updates, id } });
|
|
804
|
+
},
|
|
805
|
+
|
|
806
|
+
promise<Value, T extends Data = Data>(
|
|
807
|
+
promiseValue: Promise<Value>,
|
|
808
|
+
options: ToastManagerPromiseOptions<Value, T>,
|
|
809
|
+
): Promise<Value> {
|
|
810
|
+
let handledPromise = promiseValue;
|
|
811
|
+
|
|
812
|
+
emit({
|
|
813
|
+
action: 'promise',
|
|
814
|
+
options: {
|
|
815
|
+
...options,
|
|
816
|
+
promise: promiseValue,
|
|
817
|
+
setPromise(promise: Promise<Value>) {
|
|
818
|
+
handledPromise = promise;
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
return handledPromise;
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// --- Contexts -----------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
const ToastContext = createContext<ToastStore | undefined>(undefined);
|
|
831
|
+
|
|
832
|
+
export function useToastProviderContext(): ToastStore {
|
|
833
|
+
const context = useContext(ToastContext);
|
|
834
|
+
if (!context) {
|
|
835
|
+
throw new Error('Base UI: useToastManager must be used within <Toast.Provider>.');
|
|
836
|
+
}
|
|
837
|
+
return context;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export interface ToastRootContextValue {
|
|
841
|
+
toast: ToastObject<any>;
|
|
842
|
+
rootRef: { current: HTMLElement | null };
|
|
843
|
+
titleId: string | undefined;
|
|
844
|
+
setTitleId: (value: string | undefined) => void;
|
|
845
|
+
descriptionId: string | undefined;
|
|
846
|
+
setDescriptionId: (value: string | undefined) => void;
|
|
847
|
+
swiping: boolean;
|
|
848
|
+
swipeDirection: SwipeDirection | undefined;
|
|
849
|
+
index: number;
|
|
850
|
+
visibleIndex: number;
|
|
851
|
+
expanded: boolean;
|
|
852
|
+
recalculateHeight: (flush?: boolean) => void;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const ToastRootContext = createContext<ToastRootContextValue | undefined>(undefined);
|
|
856
|
+
|
|
857
|
+
export function useToastRootContext(): ToastRootContextValue {
|
|
858
|
+
const context = useContext(ToastRootContext);
|
|
859
|
+
if (!context) {
|
|
860
|
+
throw new Error(
|
|
861
|
+
'Base UI: ToastRootContext is missing. Toast parts must be used within <Toast.Root>.',
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
return context;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
export interface ToastPositionerContextValue {
|
|
868
|
+
side: Side;
|
|
869
|
+
align: Align;
|
|
870
|
+
arrowRef: { current: Element | null };
|
|
871
|
+
arrowUncentered: boolean;
|
|
872
|
+
arrowStyles: Record<string, any>;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const ToastPositionerContext = createContext<ToastPositionerContextValue | undefined>(undefined);
|
|
876
|
+
|
|
877
|
+
export function useToastPositionerContext(): ToastPositionerContextValue {
|
|
878
|
+
const context = useContext(ToastPositionerContext);
|
|
879
|
+
if (context === undefined) {
|
|
880
|
+
throw new Error(
|
|
881
|
+
'Base UI: ToastPositionerContext is missing. ToastPositioner parts must be placed within <Toast.Positioner>.',
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
return context;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// --- useToastManager ----------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
export interface UseToastManagerReturnValue<Data extends object = any> {
|
|
890
|
+
toasts: ToastObject<Data>[];
|
|
891
|
+
add: <T extends Data = Data>(options: ToastManagerAddOptions<T>) => string;
|
|
892
|
+
close: (toastId?: string) => void;
|
|
893
|
+
update: <T extends Data = Data>(toastId: string, options: ToastManagerUpdateOptions<T>) => void;
|
|
894
|
+
promise: <Value, T extends Data = Data>(
|
|
895
|
+
promise: Promise<Value>,
|
|
896
|
+
options: ToastManagerPromiseOptions<Value, T>,
|
|
897
|
+
) => Promise<Value>;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Returns the array of toasts and methods to manage them.
|
|
902
|
+
*
|
|
903
|
+
* SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
|
|
904
|
+
*/
|
|
905
|
+
export function useToastManager<Data extends object = any>(
|
|
906
|
+
slotArg?: symbol,
|
|
907
|
+
): UseToastManagerReturnValue<Data> {
|
|
908
|
+
const slot = slotArg ?? S('useToastManager');
|
|
909
|
+
const store = useContext(ToastContext);
|
|
910
|
+
|
|
911
|
+
if (!store) {
|
|
912
|
+
throw new Error('Base UI: useToastManager must be used within <Toast.Provider>.');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const toasts = store.useState('toasts', subSlot(slot, 'toasts'));
|
|
916
|
+
|
|
917
|
+
return useMemo(
|
|
918
|
+
() => ({
|
|
919
|
+
toasts,
|
|
920
|
+
add: store.addToast,
|
|
921
|
+
close: store.closeToast,
|
|
922
|
+
update: store.updateToast,
|
|
923
|
+
promise: store.promiseToast,
|
|
924
|
+
}),
|
|
925
|
+
[toasts, store],
|
|
926
|
+
subSlot(slot, 'm'),
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// --- Provider -----------------------------------------------------------------
|
|
931
|
+
|
|
932
|
+
function ToastProvider(props: any): any {
|
|
933
|
+
const slot = S('ToastProvider');
|
|
934
|
+
const { children, timeout = 5000, limit = 3, toastManager } = props;
|
|
935
|
+
|
|
936
|
+
const store = useRefWithInit<ToastStore>(
|
|
937
|
+
() =>
|
|
938
|
+
new ToastStore({
|
|
939
|
+
timeout,
|
|
940
|
+
limit,
|
|
941
|
+
viewport: null,
|
|
942
|
+
toasts: [],
|
|
943
|
+
hovering: false,
|
|
944
|
+
focused: false,
|
|
945
|
+
isWindowFocused: true,
|
|
946
|
+
prevFocusElement: null,
|
|
947
|
+
}),
|
|
948
|
+
subSlot(slot, 'store'),
|
|
949
|
+
).current;
|
|
950
|
+
|
|
951
|
+
useOnMount(store.disposeEffect, subSlot(slot, 'dispose'));
|
|
952
|
+
|
|
953
|
+
useEffect(
|
|
954
|
+
function subscribeToToastManager() {
|
|
955
|
+
if (!toastManager) {
|
|
956
|
+
return undefined;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const unsubscribe = toastManager[' subscribe'](({ action, options }: ToastManagerEvent) => {
|
|
960
|
+
const id = options.id;
|
|
961
|
+
|
|
962
|
+
if (action === 'promise' && options.promise) {
|
|
963
|
+
store.promiseToast(options.promise, options);
|
|
964
|
+
} else if (action === 'update' && id) {
|
|
965
|
+
store.updateToast(id, options);
|
|
966
|
+
} else if (action === 'close') {
|
|
967
|
+
store.closeToast(id);
|
|
968
|
+
} else {
|
|
969
|
+
store.addToast(options);
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
return unsubscribe;
|
|
974
|
+
},
|
|
975
|
+
[store, toastManager],
|
|
976
|
+
subSlot(slot, 'e:subscribe'),
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// `limit` needs custom syncing because changing it must also recompute each toast's `limited`
|
|
980
|
+
// flag; `useSyncedValues` would only update the raw value.
|
|
981
|
+
useLayoutEffect(
|
|
982
|
+
() => {
|
|
983
|
+
store.syncProviderProps({ timeout, limit });
|
|
984
|
+
},
|
|
985
|
+
[store, timeout, limit],
|
|
986
|
+
subSlot(slot, 'e:sync'),
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
return createElement(ToastContext.Provider, { value: store, children });
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// --- Viewport -----------------------------------------------------------------
|
|
993
|
+
|
|
994
|
+
function ToastViewport(componentProps: any): any {
|
|
995
|
+
const slot = S('ToastViewport');
|
|
996
|
+
const { render, className, style, children, ref: forwardedRef, ...elementProps } = componentProps;
|
|
997
|
+
|
|
998
|
+
const store = useToastProviderContext();
|
|
999
|
+
const windowFocusTimeout = useTimeout(subSlot(slot, 'wft'));
|
|
1000
|
+
|
|
1001
|
+
const handlingFocusGuardRef = useRef(false, subSlot(slot, 'hfg'));
|
|
1002
|
+
const markedReadyForMouseLeaveRef = useRef(false, subSlot(slot, 'mrml'));
|
|
1003
|
+
const touchActiveRef = useRef(false, subSlot(slot, 'touch'));
|
|
1004
|
+
|
|
1005
|
+
const isEmpty = store.useState('isEmpty', subSlot(slot, 'empty'));
|
|
1006
|
+
const toasts = store.useState('toasts', subSlot(slot, 'toasts'));
|
|
1007
|
+
const focused = store.useState('focused', subSlot(slot, 'focused'));
|
|
1008
|
+
const expanded = store.useState('expanded', subSlot(slot, 'expanded'));
|
|
1009
|
+
const prevFocusElement = store.useState('prevFocusElement', subSlot(slot, 'prevFocus'));
|
|
1010
|
+
const frontmostHeight = toasts[0]?.height ?? 0;
|
|
1011
|
+
|
|
1012
|
+
const hasTransitioningToasts = useMemo(
|
|
1013
|
+
() => toasts.some((toast: ToastObject) => toast.transitionStatus === 'ending'),
|
|
1014
|
+
[toasts],
|
|
1015
|
+
subSlot(slot, 'transitioning'),
|
|
1016
|
+
);
|
|
1017
|
+
const highPriorityToasts = useMemo(
|
|
1018
|
+
() => toasts.filter((toast: ToastObject) => toast.priority === 'high'),
|
|
1019
|
+
[toasts],
|
|
1020
|
+
subSlot(slot, 'highPriority'),
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
// Listen globally for F6 so we can force-focus the viewport.
|
|
1024
|
+
useEffect(
|
|
1025
|
+
() => {
|
|
1026
|
+
const viewport = store.state.viewport;
|
|
1027
|
+
if (!viewport) {
|
|
1028
|
+
return undefined;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function handleGlobalKeyDown(event: Event) {
|
|
1032
|
+
if (isEmpty) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if ((event as KeyboardEvent).key === 'F6' && getTarget(event) !== viewport) {
|
|
1037
|
+
event.preventDefault();
|
|
1038
|
+
store.setPrevFocusElement(activeElement(ownerDocument(viewport)) as HTMLElement | null);
|
|
1039
|
+
viewport?.focus({ preventScroll: true });
|
|
1040
|
+
store.pauseTimers();
|
|
1041
|
+
store.setFocused(true);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const win = ownerWindow(viewport);
|
|
1046
|
+
return addEventListener(win, 'keydown', handleGlobalKeyDown);
|
|
1047
|
+
},
|
|
1048
|
+
[store, isEmpty],
|
|
1049
|
+
subSlot(slot, 'e:f6'),
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
useEffect(
|
|
1053
|
+
() => {
|
|
1054
|
+
const viewport = store.state.viewport;
|
|
1055
|
+
if (!viewport || isEmpty) {
|
|
1056
|
+
return undefined;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const win = ownerWindow(viewport);
|
|
1060
|
+
|
|
1061
|
+
function handleWindowBlur(event: Event) {
|
|
1062
|
+
if (getTarget(event) !== win) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
store.setIsWindowFocused(false);
|
|
1067
|
+
store.pauseTimers();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function handleWindowFocus(event: Event) {
|
|
1071
|
+
if ((event as FocusEvent).relatedTarget) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const target = getTarget(event);
|
|
1076
|
+
const activeEl = activeElement(ownerDocument(viewport));
|
|
1077
|
+
if (
|
|
1078
|
+
target === win ||
|
|
1079
|
+
!contains(viewport, target as HTMLElement | null) ||
|
|
1080
|
+
!isFocusVisible(activeEl)
|
|
1081
|
+
) {
|
|
1082
|
+
store.resumeTimers();
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Wait for the `handleFocus` event to fire.
|
|
1086
|
+
windowFocusTimeout.start(0, () => store.setIsWindowFocused(true));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return mergeCleanups(
|
|
1090
|
+
addEventListener(win, 'blur', handleWindowBlur, true),
|
|
1091
|
+
addEventListener(win, 'focus', handleWindowFocus, true),
|
|
1092
|
+
);
|
|
1093
|
+
},
|
|
1094
|
+
[
|
|
1095
|
+
store,
|
|
1096
|
+
windowFocusTimeout,
|
|
1097
|
+
// `store.state.viewport` isn't available on the first render, since the portal node
|
|
1098
|
+
// hasn't yet been created. This dependency ensures the window listeners are added once
|
|
1099
|
+
// toasts have been created and the ref is available.
|
|
1100
|
+
isEmpty,
|
|
1101
|
+
],
|
|
1102
|
+
subSlot(slot, 'e:window'),
|
|
1103
|
+
);
|
|
1104
|
+
|
|
1105
|
+
useEffect(
|
|
1106
|
+
() => {
|
|
1107
|
+
const viewport = store.state.viewport;
|
|
1108
|
+
if (!viewport || isEmpty) {
|
|
1109
|
+
return undefined;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const doc = ownerDocument(viewport);
|
|
1113
|
+
return addEventListener(doc, 'pointerdown', store.handleDocumentPointerDown, true);
|
|
1114
|
+
},
|
|
1115
|
+
[isEmpty, store],
|
|
1116
|
+
subSlot(slot, 'e:pointerdown'),
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
function handleFocusGuard(event: FocusEvent) {
|
|
1120
|
+
const viewport = store.state.viewport;
|
|
1121
|
+
if (!viewport) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
handlingFocusGuardRef.current = true;
|
|
1126
|
+
|
|
1127
|
+
// If we're coming off the container, move to the first toast that can hold focus, skipping
|
|
1128
|
+
// toasts that are animating out or inert because they're limited.
|
|
1129
|
+
if (event.relatedTarget === viewport) {
|
|
1130
|
+
const firstFocusableToast = toasts.find(
|
|
1131
|
+
(toast: ToastObject) => toast.transitionStatus !== 'ending' && !toast.limited,
|
|
1132
|
+
);
|
|
1133
|
+
if (firstFocusableToast) {
|
|
1134
|
+
firstFocusableToast.ref?.current?.focus();
|
|
1135
|
+
} else {
|
|
1136
|
+
store.restoreFocusToPrevElement();
|
|
1137
|
+
}
|
|
1138
|
+
} else {
|
|
1139
|
+
store.restoreFocusToPrevElement();
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
1144
|
+
if (event.key === 'Tab' && event.shiftKey && getTarget(event) === store.state.viewport) {
|
|
1145
|
+
event.preventDefault();
|
|
1146
|
+
store.restoreFocusToPrevElement();
|
|
1147
|
+
// Shift+Tab is explicit keyboard navigation out of the viewport.
|
|
1148
|
+
store.resumeTimers();
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function flushMouseLeave() {
|
|
1153
|
+
const hasEndingToasts = store.state.toasts.some(
|
|
1154
|
+
(toast: ToastObject) => toast.transitionStatus === 'ending',
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
if (hasEndingToasts || touchActiveRef.current || !markedReadyForMouseLeaveRef.current) {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Once transitions have finished, see if a mouseleave was already triggered but blocked from
|
|
1162
|
+
// taking effect. If so, we can now safely collapse the viewport without restarting timers
|
|
1163
|
+
// while the window is blurred.
|
|
1164
|
+
if (store.state.isWindowFocused) {
|
|
1165
|
+
store.resumeTimers();
|
|
1166
|
+
}
|
|
1167
|
+
store.setHovering(false);
|
|
1168
|
+
markedReadyForMouseLeaveRef.current = false;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
useEffect(flushMouseLeave, [hasTransitioningToasts, store], subSlot(slot, 'e:flushLeave'));
|
|
1172
|
+
|
|
1173
|
+
function handleMouseEnter() {
|
|
1174
|
+
store.pauseTimers();
|
|
1175
|
+
store.setHovering(true);
|
|
1176
|
+
markedReadyForMouseLeaveRef.current = false;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function resumeTimersIfWindowFocused() {
|
|
1180
|
+
if (store.state.isWindowFocused) {
|
|
1181
|
+
store.resumeTimers();
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function handleMouseLeave() {
|
|
1186
|
+
const hasEndingToasts = store.state.toasts.some(
|
|
1187
|
+
(toast: ToastObject) => toast.transitionStatus === 'ending',
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
if (hasEndingToasts || touchActiveRef.current) {
|
|
1191
|
+
// When swiping to dismiss, wait until the transitions have settled or the touch
|
|
1192
|
+
// interaction ends to avoid collapsing mid-gesture.
|
|
1193
|
+
markedReadyForMouseLeaveRef.current = true;
|
|
1194
|
+
} else {
|
|
1195
|
+
resumeTimersIfWindowFocused();
|
|
1196
|
+
store.setHovering(false);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function handlePointerDown(event: PointerEvent) {
|
|
1201
|
+
if (event.pointerType === 'touch') {
|
|
1202
|
+
touchActiveRef.current = true;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function handlePointerEnd(event: PointerEvent) {
|
|
1207
|
+
if (event.pointerType !== 'touch') {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
touchActiveRef.current = false;
|
|
1212
|
+
flushMouseLeave();
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function handleFocus() {
|
|
1216
|
+
if (handlingFocusGuardRef.current) {
|
|
1217
|
+
handlingFocusGuardRef.current = false;
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (focused) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Only set focused when the active element is focus-visible. This prevents the viewport from
|
|
1226
|
+
// staying expanded when clicking inside without keyboard navigation.
|
|
1227
|
+
if (isFocusVisible(activeElement(ownerDocument(store.state.viewport)))) {
|
|
1228
|
+
store.setFocused(true);
|
|
1229
|
+
store.pauseTimers();
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function handleBlur(event: FocusEvent) {
|
|
1234
|
+
if (!focused || contains(store.state.viewport, event.relatedTarget as HTMLElement | null)) {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
store.setFocused(false);
|
|
1239
|
+
resumeTimersIfWindowFocused();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const defaultProps: HTMLProps = {
|
|
1243
|
+
tabIndex: -1,
|
|
1244
|
+
role: 'region',
|
|
1245
|
+
'aria-live': 'polite',
|
|
1246
|
+
'aria-atomic': false,
|
|
1247
|
+
'aria-relevant': 'additions text',
|
|
1248
|
+
'aria-label': 'Notifications',
|
|
1249
|
+
onMouseEnter: handleMouseEnter,
|
|
1250
|
+
onMouseMove: handleMouseEnter,
|
|
1251
|
+
onMouseLeave: handleMouseLeave,
|
|
1252
|
+
onFocus: handleFocus,
|
|
1253
|
+
onBlur: handleBlur,
|
|
1254
|
+
onKeyDown: handleKeyDown,
|
|
1255
|
+
onClick: handleFocus,
|
|
1256
|
+
onPointerDown: handlePointerDown,
|
|
1257
|
+
onPointerUp: handlePointerEnd,
|
|
1258
|
+
onPointerCancel: handlePointerEnd,
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
const state: ToastViewportState = {
|
|
1262
|
+
expanded,
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const showGuards = !isEmpty && Boolean(prevFocusElement);
|
|
1266
|
+
|
|
1267
|
+
const element = useRenderElement(
|
|
1268
|
+
'div',
|
|
1269
|
+
componentProps,
|
|
1270
|
+
{
|
|
1271
|
+
ref: [forwardedRef, store.setViewport],
|
|
1272
|
+
state,
|
|
1273
|
+
props: [
|
|
1274
|
+
defaultProps,
|
|
1275
|
+
{
|
|
1276
|
+
style: {
|
|
1277
|
+
[ToastViewportCssVars.frontmostHeight]: frontmostHeight
|
|
1278
|
+
? `${frontmostHeight}px`
|
|
1279
|
+
: undefined,
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
elementProps,
|
|
1283
|
+
{
|
|
1284
|
+
children: [
|
|
1285
|
+
showGuards
|
|
1286
|
+
? createElement(FocusGuard, { key: 'guard-in-start', onFocus: handleFocusGuard })
|
|
1287
|
+
: null,
|
|
1288
|
+
createElement(Fragment, { key: 'children', children }),
|
|
1289
|
+
showGuards
|
|
1290
|
+
? createElement(FocusGuard, { key: 'guard-in-end', onFocus: handleFocusGuard })
|
|
1291
|
+
: null,
|
|
1292
|
+
],
|
|
1293
|
+
},
|
|
1294
|
+
],
|
|
1295
|
+
},
|
|
1296
|
+
subSlot(slot, 're'),
|
|
1297
|
+
);
|
|
1298
|
+
|
|
1299
|
+
// octane reconciles a returned ARRAY as a keyed list, so the siblings carry stable keys —
|
|
1300
|
+
// otherwise an unkeyed guard appearing/disappearing shifts the viewport element and rebuilds it.
|
|
1301
|
+
return [
|
|
1302
|
+
showGuards
|
|
1303
|
+
? createElement(FocusGuard, { key: 'guard-out-start', onFocus: handleFocusGuard })
|
|
1304
|
+
: null,
|
|
1305
|
+
createElement(Fragment, { key: 'viewport', children: element }),
|
|
1306
|
+
!focused && highPriorityToasts.length > 0
|
|
1307
|
+
? createElement('div', {
|
|
1308
|
+
key: 'live-region',
|
|
1309
|
+
style: visuallyHidden,
|
|
1310
|
+
children: highPriorityToasts.map((toast: ToastObject) =>
|
|
1311
|
+
createElement('div', {
|
|
1312
|
+
key: toast.id,
|
|
1313
|
+
role: 'alert',
|
|
1314
|
+
'aria-atomic': true,
|
|
1315
|
+
children: [
|
|
1316
|
+
createElement('div', { key: 'title', children: toast.title }),
|
|
1317
|
+
createElement('div', { key: 'description', children: toast.description }),
|
|
1318
|
+
],
|
|
1319
|
+
}),
|
|
1320
|
+
),
|
|
1321
|
+
})
|
|
1322
|
+
: null,
|
|
1323
|
+
];
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
export interface ToastViewportState {
|
|
1327
|
+
/** Whether toasts are expanded in the viewport. */
|
|
1328
|
+
expanded: boolean;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// --- Root ---------------------------------------------------------------------
|
|
1332
|
+
|
|
1333
|
+
const toastRootStateAttributesMapping: StateAttributesMapping<any> = {
|
|
1334
|
+
...transitionStatusMapping,
|
|
1335
|
+
swipeDirection(value: SwipeDirection | undefined) {
|
|
1336
|
+
return value ? { 'data-swipe-direction': value } : null;
|
|
1337
|
+
},
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
const SWIPE_THRESHOLD = 40;
|
|
1341
|
+
const REVERSE_CANCEL_THRESHOLD = 10;
|
|
1342
|
+
const OPPOSITE_DIRECTION_DAMPING_FACTOR = 0.5;
|
|
1343
|
+
const MIN_DRAG_THRESHOLD = 1;
|
|
1344
|
+
const TOAST_SWIPE_IGNORE_SELECTOR = `${BASE_UI_SWIPE_IGNORE_SELECTOR},${LEGACY_SWIPE_IGNORE_SELECTOR}`;
|
|
1345
|
+
|
|
1346
|
+
// Inlined from `utils/useSwipeDismiss` — Toast uses only these two pure helpers, not the hook. The
|
|
1347
|
+
// hook itself is Drawer-only and lands in Phase 6.
|
|
1348
|
+
function getDisplacement(direction: SwipeDirection, deltaX: number, deltaY: number) {
|
|
1349
|
+
switch (direction) {
|
|
1350
|
+
case 'up':
|
|
1351
|
+
return -deltaY;
|
|
1352
|
+
case 'down':
|
|
1353
|
+
return deltaY;
|
|
1354
|
+
case 'left':
|
|
1355
|
+
return -deltaX;
|
|
1356
|
+
case 'right':
|
|
1357
|
+
return deltaX;
|
|
1358
|
+
default:
|
|
1359
|
+
return 0;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function getElementTransform(element: HTMLElement) {
|
|
1364
|
+
const computedStyle = ownerWindow(element).getComputedStyle(element);
|
|
1365
|
+
const transform = computedStyle.transform;
|
|
1366
|
+
let translateX = 0;
|
|
1367
|
+
let translateY = 0;
|
|
1368
|
+
let scale = 1;
|
|
1369
|
+
|
|
1370
|
+
if (transform && transform !== 'none') {
|
|
1371
|
+
const matrix = transform.match(/matrix(?:3d)?\(([^)]+)\)/);
|
|
1372
|
+
if (matrix) {
|
|
1373
|
+
const values = matrix[1].split(', ').map(parseFloat);
|
|
1374
|
+
if (values.length === 6) {
|
|
1375
|
+
translateX = values[4];
|
|
1376
|
+
translateY = values[5];
|
|
1377
|
+
scale = Math.sqrt(values[0] * values[0] + values[1] * values[1]);
|
|
1378
|
+
} else if (values.length === 16) {
|
|
1379
|
+
translateX = values[12];
|
|
1380
|
+
translateY = values[13];
|
|
1381
|
+
scale = values[0];
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return { x: translateX, y: translateY, scale };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function ToastRoot(componentProps: any): any {
|
|
1390
|
+
const slot = S('ToastRoot');
|
|
1391
|
+
const {
|
|
1392
|
+
toast,
|
|
1393
|
+
render,
|
|
1394
|
+
className,
|
|
1395
|
+
swipeDirection = ['down', 'right'],
|
|
1396
|
+
style,
|
|
1397
|
+
ref: forwardedRef,
|
|
1398
|
+
...elementProps
|
|
1399
|
+
} = componentProps;
|
|
1400
|
+
|
|
1401
|
+
const isAnchored = toast.positionerProps?.anchor !== undefined;
|
|
1402
|
+
|
|
1403
|
+
let swipeDirections: SwipeDirection[] = [];
|
|
1404
|
+
if (!isAnchored) {
|
|
1405
|
+
swipeDirections = Array.isArray(swipeDirection) ? swipeDirection : [swipeDirection];
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const swipeEnabled = swipeDirections.length > 0;
|
|
1409
|
+
|
|
1410
|
+
const store = useToastProviderContext();
|
|
1411
|
+
|
|
1412
|
+
const [currentSwipeDirection, setCurrentSwipeDirection] = useState<SwipeDirection | undefined>(
|
|
1413
|
+
undefined,
|
|
1414
|
+
subSlot(slot, 'csd'),
|
|
1415
|
+
);
|
|
1416
|
+
const [isSwiping, setIsSwiping] = useState(false, subSlot(slot, 'swiping'));
|
|
1417
|
+
const [isRealSwipe, setIsRealSwipe] = useState(false, subSlot(slot, 'realSwipe'));
|
|
1418
|
+
const [dragDismissed, setDragDismissed] = useState(false, subSlot(slot, 'dismissed'));
|
|
1419
|
+
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }, subSlot(slot, 'offset'));
|
|
1420
|
+
const [initialTransform, setInitialTransform] = useState(
|
|
1421
|
+
{ x: 0, y: 0, scale: 1 },
|
|
1422
|
+
subSlot(slot, 'initTransform'),
|
|
1423
|
+
);
|
|
1424
|
+
const [titleId, setTitleId] = useState<string | undefined>(undefined, subSlot(slot, 'titleId'));
|
|
1425
|
+
const [descriptionId, setDescriptionId] = useState<string | undefined>(
|
|
1426
|
+
undefined,
|
|
1427
|
+
subSlot(slot, 'descId'),
|
|
1428
|
+
);
|
|
1429
|
+
const [lockedDirection, setLockedDirection] = useState<'horizontal' | 'vertical' | null>(
|
|
1430
|
+
null,
|
|
1431
|
+
subSlot(slot, 'locked'),
|
|
1432
|
+
);
|
|
1433
|
+
|
|
1434
|
+
const rootRef = useRef<HTMLElement | null>(null, subSlot(slot, 'root'));
|
|
1435
|
+
const dragStartPosRef = useRef({ x: 0, y: 0 }, subSlot(slot, 'dragStart'));
|
|
1436
|
+
const initialTransformRef = useRef({ x: 0, y: 0, scale: 1 }, subSlot(slot, 'itRef'));
|
|
1437
|
+
const intendedSwipeDirectionRef = useRef<SwipeDirection | undefined>(
|
|
1438
|
+
undefined,
|
|
1439
|
+
subSlot(slot, 'intended'),
|
|
1440
|
+
);
|
|
1441
|
+
const maxSwipeDisplacementRef = useRef(0, subSlot(slot, 'maxDisp'));
|
|
1442
|
+
const cancelledSwipeRef = useRef(false, subSlot(slot, 'cancelled'));
|
|
1443
|
+
const swipeCancelBaselineRef = useRef({ x: 0, y: 0 }, subSlot(slot, 'baseline'));
|
|
1444
|
+
const isFirstPointerMoveRef = useRef(false, subSlot(slot, 'firstMove'));
|
|
1445
|
+
const dragOffsetRef = useRef({ x: 0, y: 0 }, subSlot(slot, 'offsetRef'));
|
|
1446
|
+
const activePointerIdRef = useRef<number | null>(null, subSlot(slot, 'pointerId'));
|
|
1447
|
+
const dragAbortControllerRef = useRef<AbortController | null>(null, subSlot(slot, 'abort'));
|
|
1448
|
+
|
|
1449
|
+
const domIndex = store.useState('toastIndex', subSlot(slot, 'domIndex'), toast.id);
|
|
1450
|
+
const visibleIndex = store.useState('toastVisibleIndex', subSlot(slot, 'visIndex'), toast.id);
|
|
1451
|
+
const offsetY = store.useState('toastOffsetY', subSlot(slot, 'offsetY'), toast.id);
|
|
1452
|
+
const focused = store.useState('focused', subSlot(slot, 'focused'));
|
|
1453
|
+
const expanded = store.useState('expanded', subSlot(slot, 'expanded'));
|
|
1454
|
+
|
|
1455
|
+
useOpenChangeComplete(
|
|
1456
|
+
{
|
|
1457
|
+
open: toast.transitionStatus !== 'ending',
|
|
1458
|
+
ref: rootRef,
|
|
1459
|
+
onComplete() {
|
|
1460
|
+
if (toast.transitionStatus === 'ending') {
|
|
1461
|
+
store.removeToast(toast.id);
|
|
1462
|
+
}
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
subSlot(slot, 'occ'),
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
// Recalculates the natural height of the toast and updates it in the toast manager.
|
|
1469
|
+
// `flushSync` avoids visual flickers when called from observer callbacks.
|
|
1470
|
+
const recalculateHeight = useStableCallback(
|
|
1471
|
+
(shouldFlush: boolean = false) => {
|
|
1472
|
+
const element = rootRef.current;
|
|
1473
|
+
if (!element) {
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const previousHeight = element.style.height;
|
|
1478
|
+
element.style.height = 'auto';
|
|
1479
|
+
|
|
1480
|
+
const height = element.offsetHeight;
|
|
1481
|
+
|
|
1482
|
+
element.style.height = previousHeight;
|
|
1483
|
+
|
|
1484
|
+
function update() {
|
|
1485
|
+
store.updateToastInternal(toast.id, {
|
|
1486
|
+
ref: rootRef,
|
|
1487
|
+
height,
|
|
1488
|
+
...(toast.transitionStatus === 'starting' ? { transitionStatus: undefined } : {}),
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (shouldFlush) {
|
|
1493
|
+
flushSync(update);
|
|
1494
|
+
} else {
|
|
1495
|
+
update();
|
|
1496
|
+
}
|
|
1497
|
+
},
|
|
1498
|
+
subSlot(slot, 'recalc'),
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
useLayoutEffect(recalculateHeight, [recalculateHeight], subSlot(slot, 'e:recalc'));
|
|
1502
|
+
|
|
1503
|
+
function setResolvedDragOffset(nextDragOffset: { x: number; y: number }) {
|
|
1504
|
+
dragOffsetRef.current = nextDragOffset;
|
|
1505
|
+
setDragOffset(nextDragOffset);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
useLayoutEffect(
|
|
1509
|
+
() => {
|
|
1510
|
+
return () => {
|
|
1511
|
+
dragAbortControllerRef.current?.abort();
|
|
1512
|
+
};
|
|
1513
|
+
},
|
|
1514
|
+
[],
|
|
1515
|
+
subSlot(slot, 'e:abort'),
|
|
1516
|
+
);
|
|
1517
|
+
|
|
1518
|
+
function applyDirectionalDamping(deltaX: number, deltaY: number) {
|
|
1519
|
+
let newDeltaX = deltaX;
|
|
1520
|
+
let newDeltaY = deltaY;
|
|
1521
|
+
|
|
1522
|
+
if (!swipeDirections.includes('left') && !swipeDirections.includes('right')) {
|
|
1523
|
+
newDeltaX =
|
|
1524
|
+
deltaX > 0
|
|
1525
|
+
? deltaX ** OPPOSITE_DIRECTION_DAMPING_FACTOR
|
|
1526
|
+
: -(Math.abs(deltaX) ** OPPOSITE_DIRECTION_DAMPING_FACTOR);
|
|
1527
|
+
} else {
|
|
1528
|
+
if (!swipeDirections.includes('right') && deltaX > 0) {
|
|
1529
|
+
newDeltaX = deltaX ** OPPOSITE_DIRECTION_DAMPING_FACTOR;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (!swipeDirections.includes('left') && deltaX < 0) {
|
|
1533
|
+
newDeltaX = -(Math.abs(deltaX) ** OPPOSITE_DIRECTION_DAMPING_FACTOR);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (!swipeDirections.includes('up') && !swipeDirections.includes('down')) {
|
|
1538
|
+
newDeltaY =
|
|
1539
|
+
deltaY > 0
|
|
1540
|
+
? deltaY ** OPPOSITE_DIRECTION_DAMPING_FACTOR
|
|
1541
|
+
: -(Math.abs(deltaY) ** OPPOSITE_DIRECTION_DAMPING_FACTOR);
|
|
1542
|
+
} else {
|
|
1543
|
+
if (!swipeDirections.includes('down') && deltaY > 0) {
|
|
1544
|
+
newDeltaY = deltaY ** OPPOSITE_DIRECTION_DAMPING_FACTOR;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
if (!swipeDirections.includes('up') && deltaY < 0) {
|
|
1548
|
+
newDeltaY = -(Math.abs(deltaY) ** OPPOSITE_DIRECTION_DAMPING_FACTOR);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
return { x: newDeltaX, y: newDeltaY };
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const handleSwipeEnd = useStableCallback(
|
|
1556
|
+
(event: PointerEvent) => {
|
|
1557
|
+
if (event.pointerId !== activePointerIdRef.current) {
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
activePointerIdRef.current = null;
|
|
1562
|
+
dragAbortControllerRef.current?.abort();
|
|
1563
|
+
dragAbortControllerRef.current = null;
|
|
1564
|
+
setIsSwiping(false);
|
|
1565
|
+
setIsRealSwipe(false);
|
|
1566
|
+
setLockedDirection(null);
|
|
1567
|
+
|
|
1568
|
+
const resolvedInitialTransform = initialTransformRef.current;
|
|
1569
|
+
|
|
1570
|
+
if (event.type === 'pointercancel' || cancelledSwipeRef.current) {
|
|
1571
|
+
setResolvedDragOffset({ x: resolvedInitialTransform.x, y: resolvedInitialTransform.y });
|
|
1572
|
+
setCurrentSwipeDirection(undefined);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
let shouldClose = false;
|
|
1577
|
+
const resolvedDragOffset = dragOffsetRef.current;
|
|
1578
|
+
const deltaX = resolvedDragOffset.x - resolvedInitialTransform.x;
|
|
1579
|
+
const deltaY = resolvedDragOffset.y - resolvedInitialTransform.y;
|
|
1580
|
+
let dismissDirection: SwipeDirection | undefined;
|
|
1581
|
+
|
|
1582
|
+
for (const direction of swipeDirections) {
|
|
1583
|
+
switch (direction) {
|
|
1584
|
+
case 'right':
|
|
1585
|
+
if (deltaX > SWIPE_THRESHOLD) {
|
|
1586
|
+
shouldClose = true;
|
|
1587
|
+
dismissDirection = 'right';
|
|
1588
|
+
}
|
|
1589
|
+
break;
|
|
1590
|
+
case 'left':
|
|
1591
|
+
if (deltaX < -SWIPE_THRESHOLD) {
|
|
1592
|
+
shouldClose = true;
|
|
1593
|
+
dismissDirection = 'left';
|
|
1594
|
+
}
|
|
1595
|
+
break;
|
|
1596
|
+
case 'down':
|
|
1597
|
+
if (deltaY > SWIPE_THRESHOLD) {
|
|
1598
|
+
shouldClose = true;
|
|
1599
|
+
dismissDirection = 'down';
|
|
1600
|
+
}
|
|
1601
|
+
break;
|
|
1602
|
+
case 'up':
|
|
1603
|
+
if (deltaY < -SWIPE_THRESHOLD) {
|
|
1604
|
+
shouldClose = true;
|
|
1605
|
+
dismissDirection = 'up';
|
|
1606
|
+
}
|
|
1607
|
+
break;
|
|
1608
|
+
default:
|
|
1609
|
+
break;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (shouldClose) {
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (shouldClose) {
|
|
1618
|
+
setCurrentSwipeDirection(dismissDirection);
|
|
1619
|
+
setDragDismissed(true);
|
|
1620
|
+
store.closeToast(toast.id);
|
|
1621
|
+
} else {
|
|
1622
|
+
setResolvedDragOffset({ x: resolvedInitialTransform.x, y: resolvedInitialTransform.y });
|
|
1623
|
+
setCurrentSwipeDirection(undefined);
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
subSlot(slot, 'swipeEnd'),
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
function handlePointerDown(event: PointerEvent) {
|
|
1630
|
+
if (event.button !== 0) {
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
if (event.pointerType === 'touch') {
|
|
1635
|
+
store.pauseTimers();
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const target = getTarget(event) as HTMLElement | null;
|
|
1639
|
+
|
|
1640
|
+
const isInteractiveElement = target
|
|
1641
|
+
? target.closest(`button,a,input,textarea,[role="button"],${TOAST_SWIPE_IGNORE_SELECTOR}`)
|
|
1642
|
+
: false;
|
|
1643
|
+
|
|
1644
|
+
if (isInteractiveElement) {
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
cancelledSwipeRef.current = false;
|
|
1649
|
+
intendedSwipeDirectionRef.current = undefined;
|
|
1650
|
+
maxSwipeDisplacementRef.current = 0;
|
|
1651
|
+
activePointerIdRef.current = event.pointerId;
|
|
1652
|
+
dragStartPosRef.current = { x: event.clientX, y: event.clientY };
|
|
1653
|
+
swipeCancelBaselineRef.current = dragStartPosRef.current;
|
|
1654
|
+
|
|
1655
|
+
if (rootRef.current) {
|
|
1656
|
+
const transform = getElementTransform(rootRef.current);
|
|
1657
|
+
initialTransformRef.current = transform;
|
|
1658
|
+
setInitialTransform(transform);
|
|
1659
|
+
setResolvedDragOffset({
|
|
1660
|
+
x: transform.x,
|
|
1661
|
+
y: transform.y,
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
store.setHovering(true);
|
|
1666
|
+
setIsSwiping(true);
|
|
1667
|
+
setIsRealSwipe(false);
|
|
1668
|
+
setLockedDirection(null);
|
|
1669
|
+
isFirstPointerMoveRef.current = true;
|
|
1670
|
+
|
|
1671
|
+
const element = rootRef.current;
|
|
1672
|
+
if (element) {
|
|
1673
|
+
dragAbortControllerRef.current?.abort();
|
|
1674
|
+
const dragAbortController = new AbortController();
|
|
1675
|
+
dragAbortControllerRef.current = dragAbortController;
|
|
1676
|
+
|
|
1677
|
+
const doc = ownerDocument(element);
|
|
1678
|
+
doc.addEventListener('pointerup', handleSwipeEnd as EventListener, {
|
|
1679
|
+
signal: dragAbortController.signal,
|
|
1680
|
+
});
|
|
1681
|
+
doc.addEventListener('pointercancel', handleSwipeEnd as EventListener, {
|
|
1682
|
+
signal: dragAbortController.signal,
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
(element as any).setPointerCapture?.(event.pointerId);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function handlePointerMove(event: PointerEvent) {
|
|
1690
|
+
if (event.pointerId !== activePointerIdRef.current) {
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Prevent text selection on Safari
|
|
1695
|
+
event.preventDefault();
|
|
1696
|
+
|
|
1697
|
+
if (isFirstPointerMoveRef.current) {
|
|
1698
|
+
// Adjust the starting position to the current position on the first move to account for
|
|
1699
|
+
// the delay between pointerdown and the first pointermove on iOS.
|
|
1700
|
+
dragStartPosRef.current = { x: event.clientX, y: event.clientY };
|
|
1701
|
+
isFirstPointerMoveRef.current = false;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const { clientY, clientX, movementX, movementY } = event;
|
|
1705
|
+
|
|
1706
|
+
if (
|
|
1707
|
+
(movementY < 0 && clientY > swipeCancelBaselineRef.current.y) ||
|
|
1708
|
+
(movementY > 0 && clientY < swipeCancelBaselineRef.current.y)
|
|
1709
|
+
) {
|
|
1710
|
+
swipeCancelBaselineRef.current = { x: swipeCancelBaselineRef.current.x, y: clientY };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
if (
|
|
1714
|
+
(movementX < 0 && clientX > swipeCancelBaselineRef.current.x) ||
|
|
1715
|
+
(movementX > 0 && clientX < swipeCancelBaselineRef.current.x)
|
|
1716
|
+
) {
|
|
1717
|
+
swipeCancelBaselineRef.current = { x: clientX, y: swipeCancelBaselineRef.current.y };
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const deltaX = clientX - dragStartPosRef.current.x;
|
|
1721
|
+
const deltaY = clientY - dragStartPosRef.current.y;
|
|
1722
|
+
const cancelDeltaY = clientY - swipeCancelBaselineRef.current.y;
|
|
1723
|
+
const cancelDeltaX = clientX - swipeCancelBaselineRef.current.x;
|
|
1724
|
+
|
|
1725
|
+
if (!isRealSwipe) {
|
|
1726
|
+
const movementDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
1727
|
+
if (movementDistance >= MIN_DRAG_THRESHOLD) {
|
|
1728
|
+
setIsRealSwipe(true);
|
|
1729
|
+
if (lockedDirection === null) {
|
|
1730
|
+
const hasHorizontal =
|
|
1731
|
+
swipeDirections.includes('left') || swipeDirections.includes('right');
|
|
1732
|
+
const hasVertical = swipeDirections.includes('up') || swipeDirections.includes('down');
|
|
1733
|
+
if (hasHorizontal && hasVertical) {
|
|
1734
|
+
const absX = Math.abs(deltaX);
|
|
1735
|
+
const absY = Math.abs(deltaY);
|
|
1736
|
+
setLockedDirection(absX > absY ? 'horizontal' : 'vertical');
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
let candidate: SwipeDirection | undefined;
|
|
1743
|
+
if (!intendedSwipeDirectionRef.current) {
|
|
1744
|
+
if (lockedDirection === 'vertical') {
|
|
1745
|
+
if (deltaY > 0) {
|
|
1746
|
+
candidate = 'down';
|
|
1747
|
+
} else if (deltaY < 0) {
|
|
1748
|
+
candidate = 'up';
|
|
1749
|
+
}
|
|
1750
|
+
} else if (lockedDirection === 'horizontal') {
|
|
1751
|
+
if (deltaX > 0) {
|
|
1752
|
+
candidate = 'right';
|
|
1753
|
+
} else if (deltaX < 0) {
|
|
1754
|
+
candidate = 'left';
|
|
1755
|
+
}
|
|
1756
|
+
} else if (Math.abs(deltaX) >= Math.abs(deltaY)) {
|
|
1757
|
+
candidate = deltaX > 0 ? 'right' : 'left';
|
|
1758
|
+
} else {
|
|
1759
|
+
candidate = deltaY > 0 ? 'down' : 'up';
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if (candidate && swipeDirections.includes(candidate)) {
|
|
1763
|
+
intendedSwipeDirectionRef.current = candidate;
|
|
1764
|
+
maxSwipeDisplacementRef.current = getDisplacement(candidate, deltaX, deltaY);
|
|
1765
|
+
setCurrentSwipeDirection(candidate);
|
|
1766
|
+
}
|
|
1767
|
+
} else {
|
|
1768
|
+
const direction = intendedSwipeDirectionRef.current;
|
|
1769
|
+
const currentDisplacement = getDisplacement(direction, cancelDeltaX, cancelDeltaY);
|
|
1770
|
+
|
|
1771
|
+
if (currentDisplacement > SWIPE_THRESHOLD) {
|
|
1772
|
+
cancelledSwipeRef.current = false;
|
|
1773
|
+
setCurrentSwipeDirection(direction);
|
|
1774
|
+
} else if (
|
|
1775
|
+
!(swipeDirections.includes('left') && swipeDirections.includes('right')) &&
|
|
1776
|
+
!(swipeDirections.includes('up') && swipeDirections.includes('down')) &&
|
|
1777
|
+
maxSwipeDisplacementRef.current - currentDisplacement >= REVERSE_CANCEL_THRESHOLD
|
|
1778
|
+
) {
|
|
1779
|
+
// Mark that a change-of-mind has occurred
|
|
1780
|
+
cancelledSwipeRef.current = true;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const dampedDelta = applyDirectionalDamping(deltaX, deltaY);
|
|
1785
|
+
let newOffsetX = initialTransformRef.current.x;
|
|
1786
|
+
let newOffsetY = initialTransformRef.current.y;
|
|
1787
|
+
|
|
1788
|
+
if (lockedDirection === 'horizontal') {
|
|
1789
|
+
if (swipeDirections.includes('left') || swipeDirections.includes('right')) {
|
|
1790
|
+
newOffsetX += dampedDelta.x;
|
|
1791
|
+
}
|
|
1792
|
+
} else if (lockedDirection === 'vertical') {
|
|
1793
|
+
if (swipeDirections.includes('up') || swipeDirections.includes('down')) {
|
|
1794
|
+
newOffsetY += dampedDelta.y;
|
|
1795
|
+
}
|
|
1796
|
+
} else {
|
|
1797
|
+
if (swipeDirections.includes('left') || swipeDirections.includes('right')) {
|
|
1798
|
+
newOffsetX += dampedDelta.x;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (swipeDirections.includes('up') || swipeDirections.includes('down')) {
|
|
1802
|
+
newOffsetY += dampedDelta.y;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
setResolvedDragOffset({ x: newOffsetX, y: newOffsetY });
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
1810
|
+
if (event.key === 'Escape') {
|
|
1811
|
+
if (
|
|
1812
|
+
!rootRef.current ||
|
|
1813
|
+
!contains(rootRef.current, activeElement(ownerDocument(rootRef.current)))
|
|
1814
|
+
) {
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
store.closeToast(toast.id);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
useEffect(
|
|
1823
|
+
() => {
|
|
1824
|
+
if (!swipeEnabled) {
|
|
1825
|
+
return undefined;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const element = rootRef.current;
|
|
1829
|
+
if (!element) {
|
|
1830
|
+
return undefined;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function preventDefaultTouchStart(event: Event) {
|
|
1834
|
+
if (
|
|
1835
|
+
activePointerIdRef.current === null ||
|
|
1836
|
+
!contains(element, getTarget(event) as HTMLElement | null)
|
|
1837
|
+
) {
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// A pointermove `preventDefault` is not enough on iOS; this non-passive touchmove
|
|
1842
|
+
// listener blocks native scrolling while dragging.
|
|
1843
|
+
event.preventDefault();
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
return addEventListener(element, 'touchmove', preventDefaultTouchStart, {
|
|
1847
|
+
passive: false,
|
|
1848
|
+
});
|
|
1849
|
+
},
|
|
1850
|
+
[swipeEnabled],
|
|
1851
|
+
subSlot(slot, 'e:touchmove'),
|
|
1852
|
+
);
|
|
1853
|
+
|
|
1854
|
+
function getDragStyles() {
|
|
1855
|
+
if (
|
|
1856
|
+
!isSwiping &&
|
|
1857
|
+
dragOffset.x === initialTransform.x &&
|
|
1858
|
+
dragOffset.y === initialTransform.y &&
|
|
1859
|
+
!dragDismissed
|
|
1860
|
+
) {
|
|
1861
|
+
return {
|
|
1862
|
+
[ToastRootCssVars.swipeMovementX]: '0px',
|
|
1863
|
+
[ToastRootCssVars.swipeMovementY]: '0px',
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const deltaX = dragOffset.x - initialTransform.x;
|
|
1868
|
+
const deltaY = dragOffset.y - initialTransform.y;
|
|
1869
|
+
|
|
1870
|
+
return {
|
|
1871
|
+
transition: isSwiping ? 'none' : undefined,
|
|
1872
|
+
// While swiping, freeze the element at its current visual transform so it doesn't snap
|
|
1873
|
+
// to the end position.
|
|
1874
|
+
transform: isSwiping
|
|
1875
|
+
? `translateX(${dragOffset.x}px) translateY(${dragOffset.y}px) scale(${initialTransform.scale})`
|
|
1876
|
+
: undefined,
|
|
1877
|
+
[ToastRootCssVars.swipeMovementX]: `${deltaX}px`,
|
|
1878
|
+
[ToastRootCssVars.swipeMovementY]: `${deltaY}px`,
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const isHighPriority = toast.priority === 'high';
|
|
1883
|
+
|
|
1884
|
+
const defaultProps: HTMLProps = {
|
|
1885
|
+
role: isHighPriority ? 'alertdialog' : 'dialog',
|
|
1886
|
+
tabIndex: 0,
|
|
1887
|
+
'aria-modal': false,
|
|
1888
|
+
'aria-labelledby': titleId,
|
|
1889
|
+
'aria-describedby': descriptionId,
|
|
1890
|
+
'aria-hidden': isHighPriority && !focused ? true : undefined,
|
|
1891
|
+
onPointerDown: swipeEnabled ? handlePointerDown : undefined,
|
|
1892
|
+
onPointerMove: swipeEnabled ? handlePointerMove : undefined,
|
|
1893
|
+
onPointerUp: swipeEnabled ? handleSwipeEnd : undefined,
|
|
1894
|
+
onPointerCancel: swipeEnabled ? handleSwipeEnd : undefined,
|
|
1895
|
+
onKeyDown: handleKeyDown,
|
|
1896
|
+
inert: inertValue(toast.limited),
|
|
1897
|
+
style: {
|
|
1898
|
+
...getDragStyles(),
|
|
1899
|
+
[ToastRootCssVars.index]: toast.transitionStatus === 'ending' ? domIndex : visibleIndex,
|
|
1900
|
+
[ToastRootCssVars.offsetY]: `${offsetY}px`,
|
|
1901
|
+
[ToastRootCssVars.height]: toast.height ? `${toast.height}px` : undefined,
|
|
1902
|
+
},
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
const toastRoot: ToastRootContextValue = useMemo(
|
|
1906
|
+
() => ({
|
|
1907
|
+
rootRef,
|
|
1908
|
+
toast,
|
|
1909
|
+
titleId,
|
|
1910
|
+
setTitleId,
|
|
1911
|
+
descriptionId,
|
|
1912
|
+
setDescriptionId,
|
|
1913
|
+
swiping: isSwiping,
|
|
1914
|
+
swipeDirection: currentSwipeDirection,
|
|
1915
|
+
recalculateHeight,
|
|
1916
|
+
index: domIndex,
|
|
1917
|
+
visibleIndex,
|
|
1918
|
+
expanded,
|
|
1919
|
+
}),
|
|
1920
|
+
[
|
|
1921
|
+
toast,
|
|
1922
|
+
titleId,
|
|
1923
|
+
descriptionId,
|
|
1924
|
+
isSwiping,
|
|
1925
|
+
currentSwipeDirection,
|
|
1926
|
+
recalculateHeight,
|
|
1927
|
+
domIndex,
|
|
1928
|
+
visibleIndex,
|
|
1929
|
+
expanded,
|
|
1930
|
+
],
|
|
1931
|
+
subSlot(slot, 'ctx'),
|
|
1932
|
+
);
|
|
1933
|
+
|
|
1934
|
+
const state: ToastRootState = {
|
|
1935
|
+
transitionStatus: toast.transitionStatus,
|
|
1936
|
+
expanded,
|
|
1937
|
+
limited: toast.limited || false,
|
|
1938
|
+
type: toast.type,
|
|
1939
|
+
swiping: toastRoot.swiping,
|
|
1940
|
+
swipeDirection: toastRoot.swipeDirection,
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
const element = useRenderElement(
|
|
1944
|
+
'div',
|
|
1945
|
+
componentProps,
|
|
1946
|
+
{
|
|
1947
|
+
ref: [forwardedRef, toastRoot.rootRef],
|
|
1948
|
+
state,
|
|
1949
|
+
stateAttributesMapping: toastRootStateAttributesMapping,
|
|
1950
|
+
props: [defaultProps, elementProps],
|
|
1951
|
+
},
|
|
1952
|
+
subSlot(slot, 're'),
|
|
1953
|
+
);
|
|
1954
|
+
|
|
1955
|
+
return createElement(ToastRootContext.Provider, { value: toastRoot, children: element });
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
export interface ToastRootState {
|
|
1959
|
+
/** The transition status of the component. */
|
|
1960
|
+
transitionStatus: TransitionStatus;
|
|
1961
|
+
/** Whether the toasts in the viewport are expanded. */
|
|
1962
|
+
expanded: boolean;
|
|
1963
|
+
/** Whether the toast was limited because the toast limit was exceeded. */
|
|
1964
|
+
limited: boolean;
|
|
1965
|
+
/** The type of the toast. */
|
|
1966
|
+
type: string | undefined;
|
|
1967
|
+
/** Whether the toast is being swiped. */
|
|
1968
|
+
swiping: boolean;
|
|
1969
|
+
/** The direction the toast is being swiped. */
|
|
1970
|
+
swipeDirection: SwipeDirection | undefined;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// --- Title / Description ------------------------------------------------------
|
|
1974
|
+
|
|
1975
|
+
function ToastTitle(componentProps: any): any {
|
|
1976
|
+
const slot = S('ToastTitle');
|
|
1977
|
+
const {
|
|
1978
|
+
render,
|
|
1979
|
+
className,
|
|
1980
|
+
style,
|
|
1981
|
+
id: idProp,
|
|
1982
|
+
children: childrenProp,
|
|
1983
|
+
ref: forwardedRef,
|
|
1984
|
+
...elementProps
|
|
1985
|
+
} = componentProps;
|
|
1986
|
+
|
|
1987
|
+
const { toast, setTitleId } = useToastRootContext();
|
|
1988
|
+
|
|
1989
|
+
const children = childrenProp ?? toast.title;
|
|
1990
|
+
|
|
1991
|
+
const shouldRender = Boolean(children);
|
|
1992
|
+
|
|
1993
|
+
// RAW `useId`, not `useBaseUiId`: upstream Title/Description reach for `@base-ui/utils/useId`,
|
|
1994
|
+
// which has NO `base-ui-` prefix. Using the prefixed helper here diverged from the real package.
|
|
1995
|
+
const generatedId = useId(subSlot(slot, 'id'));
|
|
1996
|
+
const id = idProp ?? generatedId;
|
|
1997
|
+
|
|
1998
|
+
useLayoutEffect(
|
|
1999
|
+
() => {
|
|
2000
|
+
if (!shouldRender) {
|
|
2001
|
+
return undefined;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
setTitleId(id);
|
|
2005
|
+
|
|
2006
|
+
return () => {
|
|
2007
|
+
setTitleId(undefined);
|
|
2008
|
+
};
|
|
2009
|
+
},
|
|
2010
|
+
[shouldRender, id, setTitleId],
|
|
2011
|
+
subSlot(slot, 'e'),
|
|
2012
|
+
);
|
|
2013
|
+
|
|
2014
|
+
const state: ToastTitleState = {
|
|
2015
|
+
type: toast.type,
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
const element = useRenderElement(
|
|
2019
|
+
'h2',
|
|
2020
|
+
componentProps,
|
|
2021
|
+
{
|
|
2022
|
+
ref: forwardedRef,
|
|
2023
|
+
state,
|
|
2024
|
+
props: {
|
|
2025
|
+
...elementProps,
|
|
2026
|
+
id,
|
|
2027
|
+
children,
|
|
2028
|
+
},
|
|
2029
|
+
},
|
|
2030
|
+
subSlot(slot, 're'),
|
|
2031
|
+
);
|
|
2032
|
+
|
|
2033
|
+
if (!shouldRender) {
|
|
2034
|
+
return null;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
return element;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
export interface ToastTitleState {
|
|
2041
|
+
/** The type of the toast. */
|
|
2042
|
+
type: string | undefined;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function ToastDescription(componentProps: any): any {
|
|
2046
|
+
const slot = S('ToastDescription');
|
|
2047
|
+
const {
|
|
2048
|
+
render,
|
|
2049
|
+
className,
|
|
2050
|
+
style,
|
|
2051
|
+
id: idProp,
|
|
2052
|
+
children: childrenProp,
|
|
2053
|
+
ref: forwardedRef,
|
|
2054
|
+
...elementProps
|
|
2055
|
+
} = componentProps;
|
|
2056
|
+
|
|
2057
|
+
const { toast, setDescriptionId } = useToastRootContext();
|
|
2058
|
+
|
|
2059
|
+
const children = childrenProp ?? toast.description;
|
|
2060
|
+
|
|
2061
|
+
const shouldRender = Boolean(children);
|
|
2062
|
+
|
|
2063
|
+
// RAW `useId` — see the note in `ToastTitle`.
|
|
2064
|
+
const generatedId = useId(subSlot(slot, 'id'));
|
|
2065
|
+
const id = idProp ?? generatedId;
|
|
2066
|
+
|
|
2067
|
+
useLayoutEffect(
|
|
2068
|
+
() => {
|
|
2069
|
+
if (!shouldRender) {
|
|
2070
|
+
return undefined;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
setDescriptionId(id);
|
|
2074
|
+
|
|
2075
|
+
return () => {
|
|
2076
|
+
setDescriptionId(undefined);
|
|
2077
|
+
};
|
|
2078
|
+
},
|
|
2079
|
+
[shouldRender, id, setDescriptionId],
|
|
2080
|
+
subSlot(slot, 'e'),
|
|
2081
|
+
);
|
|
2082
|
+
|
|
2083
|
+
const state: ToastDescriptionState = {
|
|
2084
|
+
type: toast.type,
|
|
2085
|
+
};
|
|
2086
|
+
|
|
2087
|
+
const element = useRenderElement(
|
|
2088
|
+
'p',
|
|
2089
|
+
componentProps,
|
|
2090
|
+
{
|
|
2091
|
+
ref: forwardedRef,
|
|
2092
|
+
state,
|
|
2093
|
+
props: {
|
|
2094
|
+
...elementProps,
|
|
2095
|
+
id,
|
|
2096
|
+
children,
|
|
2097
|
+
},
|
|
2098
|
+
},
|
|
2099
|
+
subSlot(slot, 're'),
|
|
2100
|
+
);
|
|
2101
|
+
|
|
2102
|
+
if (!shouldRender) {
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
return element;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
export interface ToastDescriptionState {
|
|
2110
|
+
/** The type of the toast. */
|
|
2111
|
+
type: string | undefined;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// --- Content ------------------------------------------------------------------
|
|
2115
|
+
|
|
2116
|
+
function ToastContent(componentProps: any): any {
|
|
2117
|
+
const slot = S('ToastContent');
|
|
2118
|
+
const { render, className, style, ref: forwardedRef, ...elementProps } = componentProps;
|
|
2119
|
+
|
|
2120
|
+
const { visibleIndex, expanded, recalculateHeight } = useToastRootContext();
|
|
2121
|
+
|
|
2122
|
+
const contentRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'content'));
|
|
2123
|
+
|
|
2124
|
+
useLayoutEffect(
|
|
2125
|
+
() => {
|
|
2126
|
+
const node = contentRef.current;
|
|
2127
|
+
if (!node) {
|
|
2128
|
+
return undefined;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
recalculateHeight();
|
|
2132
|
+
|
|
2133
|
+
if (typeof ResizeObserver !== 'function' || typeof MutationObserver !== 'function') {
|
|
2134
|
+
return undefined;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
const resizeObserver = new ResizeObserver(() => recalculateHeight(true));
|
|
2138
|
+
const mutationObserver = new MutationObserver(() => recalculateHeight(true));
|
|
2139
|
+
|
|
2140
|
+
resizeObserver.observe(node);
|
|
2141
|
+
mutationObserver.observe(node, { childList: true, subtree: true, characterData: true });
|
|
2142
|
+
|
|
2143
|
+
return () => {
|
|
2144
|
+
resizeObserver.disconnect();
|
|
2145
|
+
mutationObserver.disconnect();
|
|
2146
|
+
};
|
|
2147
|
+
},
|
|
2148
|
+
[recalculateHeight],
|
|
2149
|
+
subSlot(slot, 'e'),
|
|
2150
|
+
);
|
|
2151
|
+
|
|
2152
|
+
const behind = visibleIndex > 0;
|
|
2153
|
+
|
|
2154
|
+
const state: ToastContentState = {
|
|
2155
|
+
expanded,
|
|
2156
|
+
behind,
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
return useRenderElement(
|
|
2160
|
+
'div',
|
|
2161
|
+
componentProps,
|
|
2162
|
+
{
|
|
2163
|
+
ref: [forwardedRef, contentRef],
|
|
2164
|
+
state,
|
|
2165
|
+
props: elementProps,
|
|
2166
|
+
},
|
|
2167
|
+
subSlot(slot, 're'),
|
|
2168
|
+
);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
export interface ToastContentState {
|
|
2172
|
+
/** Whether the toast viewport is expanded. */
|
|
2173
|
+
expanded: boolean;
|
|
2174
|
+
/** Whether the toast is behind the frontmost toast in the stack. */
|
|
2175
|
+
behind: boolean;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// --- Close / Action -----------------------------------------------------------
|
|
2179
|
+
|
|
2180
|
+
function ToastClose(componentProps: any): any {
|
|
2181
|
+
const slot = S('ToastClose');
|
|
2182
|
+
const {
|
|
2183
|
+
render,
|
|
2184
|
+
className,
|
|
2185
|
+
style,
|
|
2186
|
+
disabled,
|
|
2187
|
+
nativeButton = true,
|
|
2188
|
+
ref: forwardedRef,
|
|
2189
|
+
...elementProps
|
|
2190
|
+
} = componentProps;
|
|
2191
|
+
|
|
2192
|
+
const store = useToastProviderContext();
|
|
2193
|
+
const { toast } = useToastRootContext();
|
|
2194
|
+
const expanded = store.useState('expanded', subSlot(slot, 'expanded'));
|
|
2195
|
+
|
|
2196
|
+
const [hasFocus, setHasFocus] = useState(false, subSlot(slot, 'focus'));
|
|
2197
|
+
|
|
2198
|
+
const { getButtonProps, buttonRef } = useButton(
|
|
2199
|
+
{ disabled, native: nativeButton },
|
|
2200
|
+
subSlot(slot, 'btn'),
|
|
2201
|
+
);
|
|
2202
|
+
|
|
2203
|
+
const state: ToastCloseState = {
|
|
2204
|
+
type: toast.type,
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
return useRenderElement(
|
|
2208
|
+
'button',
|
|
2209
|
+
componentProps,
|
|
2210
|
+
{
|
|
2211
|
+
ref: [forwardedRef, buttonRef],
|
|
2212
|
+
state,
|
|
2213
|
+
props: [
|
|
2214
|
+
{
|
|
2215
|
+
'aria-hidden': !expanded && !hasFocus,
|
|
2216
|
+
onClick() {
|
|
2217
|
+
store.closeToast(toast.id);
|
|
2218
|
+
},
|
|
2219
|
+
onFocus() {
|
|
2220
|
+
setHasFocus(true);
|
|
2221
|
+
},
|
|
2222
|
+
onBlur() {
|
|
2223
|
+
setHasFocus(false);
|
|
2224
|
+
},
|
|
2225
|
+
},
|
|
2226
|
+
elementProps,
|
|
2227
|
+
getButtonProps,
|
|
2228
|
+
],
|
|
2229
|
+
},
|
|
2230
|
+
subSlot(slot, 're'),
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
export interface ToastCloseState {
|
|
2235
|
+
/** The type of the toast. */
|
|
2236
|
+
type: string | undefined;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function ToastAction(componentProps: any): any {
|
|
2240
|
+
const slot = S('ToastAction');
|
|
2241
|
+
const {
|
|
2242
|
+
render,
|
|
2243
|
+
className,
|
|
2244
|
+
style,
|
|
2245
|
+
disabled,
|
|
2246
|
+
nativeButton = true,
|
|
2247
|
+
ref: forwardedRef,
|
|
2248
|
+
...elementProps
|
|
2249
|
+
} = componentProps;
|
|
2250
|
+
|
|
2251
|
+
const { toast } = useToastRootContext();
|
|
2252
|
+
|
|
2253
|
+
const computedChildren = toast.actionProps?.children ?? elementProps.children;
|
|
2254
|
+
const shouldRender = Boolean(computedChildren);
|
|
2255
|
+
|
|
2256
|
+
const { getButtonProps, buttonRef } = useButton(
|
|
2257
|
+
{ disabled, native: nativeButton },
|
|
2258
|
+
subSlot(slot, 'btn'),
|
|
2259
|
+
);
|
|
2260
|
+
|
|
2261
|
+
const state: ToastActionState = {
|
|
2262
|
+
type: toast.type,
|
|
2263
|
+
};
|
|
2264
|
+
|
|
2265
|
+
const element = useRenderElement(
|
|
2266
|
+
'button',
|
|
2267
|
+
componentProps,
|
|
2268
|
+
{
|
|
2269
|
+
ref: [forwardedRef, buttonRef],
|
|
2270
|
+
state,
|
|
2271
|
+
props: [
|
|
2272
|
+
elementProps,
|
|
2273
|
+
toast.actionProps,
|
|
2274
|
+
getButtonProps,
|
|
2275
|
+
{
|
|
2276
|
+
children: computedChildren,
|
|
2277
|
+
},
|
|
2278
|
+
],
|
|
2279
|
+
},
|
|
2280
|
+
subSlot(slot, 're'),
|
|
2281
|
+
);
|
|
2282
|
+
|
|
2283
|
+
if (!shouldRender) {
|
|
2284
|
+
return null;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
return element;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
export interface ToastActionState {
|
|
2291
|
+
/** The type of the toast. */
|
|
2292
|
+
type: string | undefined;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// --- Positioner / Arrow -------------------------------------------------------
|
|
2296
|
+
|
|
2297
|
+
function ToastPositioner(componentProps: any): any {
|
|
2298
|
+
const slot = S('ToastPositioner');
|
|
2299
|
+
const { toast, ...props } = componentProps;
|
|
2300
|
+
|
|
2301
|
+
const store = useToastProviderContext();
|
|
2302
|
+
|
|
2303
|
+
const positionerProps = (toast.positionerProps ?? EMPTY_OBJECT) as HTMLProps;
|
|
2304
|
+
|
|
2305
|
+
const {
|
|
2306
|
+
render,
|
|
2307
|
+
className,
|
|
2308
|
+
anchor: anchorProp = positionerProps.anchor,
|
|
2309
|
+
positionMethod = positionerProps.positionMethod ?? 'absolute',
|
|
2310
|
+
side = positionerProps.side ?? 'top',
|
|
2311
|
+
align = positionerProps.align ?? 'center',
|
|
2312
|
+
sideOffset = positionerProps.sideOffset ?? 0,
|
|
2313
|
+
alignOffset = positionerProps.alignOffset ?? 0,
|
|
2314
|
+
collisionBoundary = positionerProps.collisionBoundary ?? 'clipping-ancestors',
|
|
2315
|
+
collisionPadding = positionerProps.collisionPadding ?? 5,
|
|
2316
|
+
arrowPadding = positionerProps.arrowPadding ?? 5,
|
|
2317
|
+
sticky = positionerProps.sticky ?? false,
|
|
2318
|
+
disableAnchorTracking = positionerProps.disableAnchorTracking ?? false,
|
|
2319
|
+
collisionAvoidance = positionerProps.collisionAvoidance ?? POPUP_COLLISION_AVOIDANCE,
|
|
2320
|
+
style,
|
|
2321
|
+
ref: forwardedRef,
|
|
2322
|
+
...elementProps
|
|
2323
|
+
} = props;
|
|
2324
|
+
|
|
2325
|
+
const [positionerElement, setPositionerElement] = useState<HTMLElement | null>(
|
|
2326
|
+
null,
|
|
2327
|
+
subSlot(slot, 'el'),
|
|
2328
|
+
);
|
|
2329
|
+
|
|
2330
|
+
const domIndex = store.useState('toastIndex', subSlot(slot, 'domIndex'), toast.id);
|
|
2331
|
+
const visibleIndex = store.useState('toastVisibleIndex', subSlot(slot, 'visIndex'), toast.id);
|
|
2332
|
+
|
|
2333
|
+
const anchor = isElement(anchorProp) ? anchorProp : null;
|
|
2334
|
+
|
|
2335
|
+
const floatingRootContext = useFloatingRootContext(
|
|
2336
|
+
{
|
|
2337
|
+
open: true,
|
|
2338
|
+
onOpenChange: NOOP,
|
|
2339
|
+
elements: {
|
|
2340
|
+
floating: positionerElement,
|
|
2341
|
+
reference: anchor,
|
|
2342
|
+
},
|
|
2343
|
+
},
|
|
2344
|
+
subSlot(slot, 'frc'),
|
|
2345
|
+
);
|
|
2346
|
+
|
|
2347
|
+
const positioning = useAnchorPositioning(
|
|
2348
|
+
{
|
|
2349
|
+
anchor,
|
|
2350
|
+
positionMethod,
|
|
2351
|
+
floatingRootContext,
|
|
2352
|
+
mounted: true,
|
|
2353
|
+
side,
|
|
2354
|
+
sideOffset,
|
|
2355
|
+
align,
|
|
2356
|
+
alignOffset,
|
|
2357
|
+
collisionBoundary,
|
|
2358
|
+
collisionPadding,
|
|
2359
|
+
sticky,
|
|
2360
|
+
arrowPadding,
|
|
2361
|
+
disableAnchorTracking,
|
|
2362
|
+
keepMounted: true,
|
|
2363
|
+
collisionAvoidance,
|
|
2364
|
+
},
|
|
2365
|
+
subSlot(slot, 'positioning'),
|
|
2366
|
+
);
|
|
2367
|
+
|
|
2368
|
+
const state: ToastPositionerState = useMemo(
|
|
2369
|
+
() => ({
|
|
2370
|
+
side: positioning.side,
|
|
2371
|
+
align: positioning.align,
|
|
2372
|
+
anchorHidden: positioning.anchorHidden,
|
|
2373
|
+
}),
|
|
2374
|
+
[positioning.side, positioning.align, positioning.anchorHidden],
|
|
2375
|
+
subSlot(slot, 'state'),
|
|
2376
|
+
);
|
|
2377
|
+
|
|
2378
|
+
const element = usePositioner(
|
|
2379
|
+
componentProps,
|
|
2380
|
+
state,
|
|
2381
|
+
{
|
|
2382
|
+
styles: {
|
|
2383
|
+
...positioning.positionerStyles,
|
|
2384
|
+
[ToastRootCssVars.index]: toast.transitionStatus === 'ending' ? domIndex : visibleIndex,
|
|
2385
|
+
},
|
|
2386
|
+
transitionStatus: toast.transitionStatus,
|
|
2387
|
+
props: elementProps,
|
|
2388
|
+
refs: [forwardedRef, setPositionerElement],
|
|
2389
|
+
},
|
|
2390
|
+
subSlot(slot, 'positioner'),
|
|
2391
|
+
);
|
|
2392
|
+
|
|
2393
|
+
return createElement(ToastPositionerContext.Provider, {
|
|
2394
|
+
value: positioning as unknown as ToastPositionerContextValue,
|
|
2395
|
+
children: element,
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
export interface ToastPositionerState {
|
|
2400
|
+
/** The side of the anchor the component is placed on. */
|
|
2401
|
+
side: Side;
|
|
2402
|
+
/** The alignment of the component relative to the anchor. */
|
|
2403
|
+
align: Align;
|
|
2404
|
+
/** Whether the anchor element is hidden. */
|
|
2405
|
+
anchorHidden: boolean;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
export interface ToastPositionerProps extends Omit<UseAnchorPositioningSharedParameters, 'anchor'> {
|
|
2409
|
+
[key: string]: any;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function ToastArrow(componentProps: any): any {
|
|
2413
|
+
const slot = S('ToastArrow');
|
|
2414
|
+
const { className, render, style, ref: forwardedRef, ...elementProps } = componentProps;
|
|
2415
|
+
|
|
2416
|
+
const { arrowRef, side, align, arrowUncentered, arrowStyles } = useToastPositionerContext();
|
|
2417
|
+
|
|
2418
|
+
const state: ToastArrowState = {
|
|
2419
|
+
side,
|
|
2420
|
+
align,
|
|
2421
|
+
uncentered: arrowUncentered,
|
|
2422
|
+
};
|
|
2423
|
+
|
|
2424
|
+
return useRenderElement(
|
|
2425
|
+
'div',
|
|
2426
|
+
componentProps,
|
|
2427
|
+
{
|
|
2428
|
+
state,
|
|
2429
|
+
ref: [forwardedRef, arrowRef],
|
|
2430
|
+
props: [{ style: arrowStyles, 'aria-hidden': true }, elementProps],
|
|
2431
|
+
},
|
|
2432
|
+
subSlot(slot, 're'),
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
export interface ToastArrowState {
|
|
2437
|
+
/** The side of the anchor the component is placed on. */
|
|
2438
|
+
side: Side;
|
|
2439
|
+
/** The alignment of the component relative to the anchor. */
|
|
2440
|
+
align: Align;
|
|
2441
|
+
/** Whether the arrow cannot be centered on the anchor. */
|
|
2442
|
+
uncentered: boolean;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// --- Namespace ----------------------------------------------------------------
|
|
2446
|
+
|
|
2447
|
+
/** Mirrors upstream's `toast/index.parts.ts`. `Portal` is `FloatingPortalLite`, as upstream. */
|
|
2448
|
+
export const Toast = {
|
|
2449
|
+
Provider: ToastProvider,
|
|
2450
|
+
Viewport: ToastViewport,
|
|
2451
|
+
Root: ToastRoot,
|
|
2452
|
+
Content: ToastContent,
|
|
2453
|
+
Description: ToastDescription,
|
|
2454
|
+
Title: ToastTitle,
|
|
2455
|
+
Close: ToastClose,
|
|
2456
|
+
Action: ToastAction,
|
|
2457
|
+
Portal: FloatingPortalLite,
|
|
2458
|
+
Positioner: ToastPositioner,
|
|
2459
|
+
Arrow: ToastArrow,
|
|
2460
|
+
useToastManager,
|
|
2461
|
+
createToastManager,
|
|
2462
|
+
};
|