varsel 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,400 +1,508 @@
1
1
  <script lang="ts">
2
- /**
3
- * @component
4
- * @description
5
- * Internal component responsible for grouping toasts by position and calculating
6
- * their stacking offsets (both collapsed and expanded).
7
- * It handles the "hover to expand" logic and manages the lifecycle of toast groups.
8
- */
9
- import VarselItem from "./VarselItem.svelte";
10
- import {
11
- ANIMATION_CONFIG,
12
- type PositionedToast,
13
- type ToastData,
14
- type ToastPosition,
15
- } from "./internals";
16
-
17
- let {
18
- toasts = [],
19
- onRemove,
20
- expandedGap = ANIMATION_CONFIG.EXPANDED_GAP,
21
- position: defaultPosition = 'bottom-center',
22
- visibleToasts = 3,
23
- expand = true,
24
- duration = 5000,
25
- closeButton = true,
26
- pauseOnHover = true,
27
- offset = undefined,
28
- dir = 'auto'
29
- }: {
30
- toasts?: ToastData[];
31
- onRemove: (id: string) => void;
32
- expandedGap?: number;
33
- position?: ToastPosition;
34
- visibleToasts?: number;
35
- expand?: boolean;
36
- duration?: number;
37
- closeButton?: boolean;
38
- pauseOnHover?: boolean;
39
- offset?: number | string;
40
- dir?: 'ltr' | 'rtl' | 'auto';
41
- } = $props();
42
-
43
- const createPositionMap = <T>(value: () => T): Record<ToastPosition, T> => ({
44
- "top-left": value(),
45
- "top-center": value(),
46
- "top-right": value(),
47
- "bottom-left": value(),
48
- "bottom-center": value(),
49
- "bottom-right": value(),
50
- });
51
-
52
- let heights = $state<Record<string, number>>({});
53
- let hovered = $state<Record<ToastPosition, boolean>>(
54
- createPositionMap(() => false),
55
- );
56
- let heldToasts = $state<Record<ToastPosition, Set<string>>>(
57
- createPositionMap(() => new Set<string>()),
58
- );
59
-
60
- // Non-reactive internal state for "previous" values (mimicking legacy behavior)
61
- let previousStackIndex: Record<string, number> = {};
62
- let previousCollapsedOffsets: Record<string, number> = {};
63
- let previousExpandedOffsets: Record<string, number> = {};
64
-
65
- let toastsByPosition = $state<Record<ToastPosition, PositionedToast[]>>(
66
- createPositionMap<PositionedToast[]>(() => []),
67
- );
68
-
69
- let collapsedOffsetData = $state<{
70
- byPosition: Record<ToastPosition, number[]>;
71
- byId: Record<string, number>;
72
- }>({ byPosition: createPositionMap<number[]>(() => []), byId: {} });
73
-
74
- let expandedOffsetData = $state<{
75
- byPosition: Record<ToastPosition, number[]>;
76
- byId: Record<string, number>;
77
- }>({ byPosition: createPositionMap<number[]>(() => []), byId: {} });
78
-
79
- let positionEntries = $derived(
80
- Object.entries(toastsByPosition) as [ToastPosition, PositionedToast[]][],
81
- );
82
- let latestPositionEntries = $derived(positionEntries);
83
- let latestHovered = $derived(hovered);
84
-
85
- const updateHoldState = (
86
- position: ToastPosition,
87
- toastId: string,
88
- isHolding: boolean,
89
- ) => {
90
- const current = heldToasts[position] ?? new Set<string>();
91
- const next = new Set(current);
92
- if (isHolding) {
93
- next.add(toastId);
94
- } else {
95
- next.delete(toastId);
96
- }
97
- if (next.size !== current.size) {
98
- heldToasts = { ...heldToasts, [position]: next };
99
- }
100
- };
101
-
102
- // Calculate toastsByPosition based on toasts
103
- $effect(() => {
104
- const grouped = createPositionMap<ToastData[]>(() => []);
105
- for (const toast of toasts) {
106
- const pos = toast.position || defaultPosition;
107
- grouped[pos].push(toast);
108
- }
109
-
110
- const nextStackIndices: Record<string, number> = {};
111
- const positioned = createPositionMap<PositionedToast[]>(() => []);
112
-
113
- for (const position of Object.keys(grouped) as ToastPosition[]) {
114
- const list = grouped[position];
115
- const activeToasts = list.filter(
116
- (toast) => !toast.isLeaving && !toast.shouldClose,
117
- );
118
- const activeIndexMap = new Map<string, number>();
119
- activeToasts.forEach((toast, activeIndex) => {
120
- activeIndexMap.set(toast.id, activeIndex);
121
- });
122
-
123
- positioned[position] = list.map((toast, orderIndex) => {
124
- let stackIndex =
125
- activeIndexMap.get(toast.id) ?? previousStackIndex[toast.id];
126
- if (stackIndex == null || Number.isNaN(stackIndex)) {
127
- stackIndex = orderIndex;
128
- }
2
+ /**
3
+ * @component
4
+ * @description
5
+ * Internal component responsible for grouping toasts by position and calculating
6
+ * their stacking offsets (both collapsed and expanded).
7
+ * It handles the "hover to expand" logic and manages the lifecycle of toast groups.
8
+ */
9
+ import VarselItem from "./VarselItem.svelte";
10
+ import {
11
+ ANIMATION_CONFIG,
12
+ FOCUSABLE_SELECTORS,
13
+ focusManager,
14
+ type PositionedToast,
15
+ type ToastData,
16
+ type ToastPosition,
17
+ } from "./internals";
18
+
19
+ let {
20
+ toasts = [],
21
+ onRemove,
22
+ expandedGap = ANIMATION_CONFIG.EXPANDED_GAP,
23
+ position: defaultPosition = "bottom-center",
24
+ visibleToasts = 3,
25
+ expand = true,
26
+ duration = 5000,
27
+ closeButton = true,
28
+ pauseOnHover = true,
29
+ offset = undefined,
30
+ dir = "auto",
31
+ }: {
32
+ toasts?: ToastData[];
33
+ onRemove: (id: string) => void;
34
+ expandedGap?: number;
35
+ position?: ToastPosition;
36
+ visibleToasts?: number;
37
+ expand?: boolean;
38
+ duration?: number;
39
+ closeButton?: boolean;
40
+ pauseOnHover?: boolean;
41
+ offset?: number | string;
42
+ dir?: "ltr" | "rtl" | "auto";
43
+ } = $props();
44
+
45
+ const createPositionMap = <T,>(value: () => T): Record<ToastPosition, T> => ({
46
+ "top-left": value(),
47
+ "top-center": value(),
48
+ "top-right": value(),
49
+ "bottom-left": value(),
50
+ "bottom-center": value(),
51
+ "bottom-right": value(),
52
+ });
53
+
54
+ let heights = $state<Record<string, number>>({});
55
+ let hovered = $state<Record<ToastPosition, boolean>>(createPositionMap(() => false));
56
+ let heldToasts = $state<Record<ToastPosition, Set<string>>>(
57
+ createPositionMap(() => new Set<string>()),
58
+ );
59
+ let isWindowFocused = $state(
60
+ typeof document === "undefined" ? true : document.visibilityState !== "hidden",
61
+ );
62
+ let isToastRegionClaimed = $state(false);
63
+
64
+ // Non-reactive internal state for "previous" values (mimicking legacy behavior)
65
+ let previousStackIndex: Record<string, number> = {};
66
+ let previousCollapsedOffsets: Record<string, number> = {};
67
+ let previousExpandedOffsets: Record<string, number> = {};
68
+
69
+ let toastsByPosition = $state<Record<ToastPosition, PositionedToast[]>>(
70
+ createPositionMap<PositionedToast[]>(() => []),
71
+ );
72
+
73
+ let collapsedOffsetData = $state<{
74
+ byPosition: Record<ToastPosition, number[]>;
75
+ byId: Record<string, number>;
76
+ }>({ byPosition: createPositionMap<number[]>(() => []), byId: {} });
77
+
78
+ let expandedOffsetData = $state<{
79
+ byPosition: Record<ToastPosition, number[]>;
80
+ byId: Record<string, number>;
81
+ }>({ byPosition: createPositionMap<number[]>(() => []), byId: {} });
82
+
83
+ let positionEntries = $derived(
84
+ Object.entries(toastsByPosition) as [ToastPosition, PositionedToast[]][],
85
+ );
86
+ let latestPositionEntries = $derived(positionEntries);
87
+ let latestHovered = $derived(hovered);
88
+
89
+ const updateHoldState = (
90
+ position: ToastPosition,
91
+ toastId: string,
92
+ isHolding: boolean,
93
+ ) => {
94
+ const current = heldToasts[position] ?? new Set<string>();
95
+ const next = new Set(current);
96
+ if (isHolding) {
97
+ next.add(toastId);
98
+ } else {
99
+ next.delete(toastId);
100
+ }
101
+ if (next.size !== current.size) {
102
+ heldToasts = { ...heldToasts, [position]: next };
103
+ }
104
+ };
105
+
106
+ // Calculate toastsByPosition based on toasts
107
+ $effect(() => {
108
+ const grouped = createPositionMap<ToastData[]>(() => []);
109
+ for (const toast of toasts) {
110
+ const pos = toast.position || defaultPosition;
111
+ grouped[pos].push(toast);
112
+ }
129
113
 
130
- nextStackIndices[toast.id] = stackIndex;
114
+ const nextStackIndices: Record<string, number> = {};
115
+ const positioned = createPositionMap<PositionedToast[]>(() => []);
116
+
117
+ for (const position of Object.keys(grouped) as ToastPosition[]) {
118
+ const list = grouped[position];
119
+ const activeToasts = list.filter(
120
+ (toast) => !toast.isLeaving && !toast.shouldClose,
121
+ );
122
+ const activeIndexMap = new Map<string, number>();
123
+ activeToasts.forEach((toast, activeIndex) => {
124
+ activeIndexMap.set(toast.id, activeIndex);
125
+ });
126
+
127
+ positioned[position] = list.map((toast, orderIndex) => {
128
+ let stackIndex = activeIndexMap.get(toast.id) ?? previousStackIndex[toast.id];
129
+ if (stackIndex == null || Number.isNaN(stackIndex)) {
130
+ stackIndex = orderIndex;
131
+ }
131
132
 
132
- return {
133
- ...toast,
134
- position: position,
135
- index: stackIndex,
136
- renderIndex: orderIndex,
137
- total: list.length,
138
- };
139
- }) as PositionedToast[];
140
- }
141
-
142
- previousStackIndex = nextStackIndices;
143
- toastsByPosition = positioned;
144
- });
145
-
146
- // Update hovered state based on empty groups
147
- $effect(() => {
148
- const next = { ...hovered };
149
- let changed = false;
150
- for (const pos of Object.keys(hovered) as ToastPosition[]) {
151
- const hasToast = (toastsByPosition[pos]?.length ?? 0) > 0;
152
- if (!hasToast && next[pos]) {
153
- next[pos] = false;
154
- changed = true;
133
+ nextStackIndices[toast.id] = stackIndex;
134
+
135
+ return {
136
+ ...toast,
137
+ position: position,
138
+ index: stackIndex,
139
+ renderIndex: orderIndex,
140
+ total: list.length,
141
+ };
142
+ }) as PositionedToast[];
155
143
  }
156
- }
157
- if (changed) {
158
- hovered = next;
159
- }
160
- });
161
-
162
- // Calculate collapsedOffsetData
163
- $effect(() => {
164
- const byPosition = createPositionMap<number[]>(() => []);
165
- const byId: Record<string, number> = {};
166
-
167
- for (const [pos, group] of positionEntries) {
168
- const isTopPosition = pos.startsWith("top-");
169
- const activeToasts = group.filter((toast) => !toast.shouldClose);
170
- const offsetsForActive: number[] = [];
171
-
172
- for (let i = 0; i < activeToasts.length; i++) {
173
- if (i === 0) {
174
- offsetsForActive.push(0);
175
- continue;
176
- }
177
144
 
178
- const prevToast = activeToasts[i - 1];
179
- const currentToast = activeToasts[i];
180
- const prevOffset = offsetsForActive[i - 1] ?? 0;
181
- if (!prevToast || !currentToast) {
182
- offsetsForActive.push(prevOffset);
183
- continue;
145
+ previousStackIndex = nextStackIndices;
146
+ toastsByPosition = positioned;
147
+ });
148
+
149
+ // Update hovered state based on empty groups
150
+ $effect(() => {
151
+ const next = { ...hovered };
152
+ let changed = false;
153
+ for (const pos of Object.keys(hovered) as ToastPosition[]) {
154
+ const hasToast = (toastsByPosition[pos]?.length ?? 0) > 0;
155
+ if (!hasToast && next[pos]) {
156
+ next[pos] = false;
157
+ changed = true;
184
158
  }
185
- const prevHeight = heights[prevToast.id];
186
- const currentHeight = heights[currentToast.id];
187
- const fallbackOffset =
188
- prevOffset + (isTopPosition ? 1 : -1) * ANIMATION_CONFIG.STACK_OFFSET;
189
-
190
- if (
191
- prevHeight == null ||
192
- currentHeight == null ||
193
- Number.isNaN(prevHeight) ||
194
- Number.isNaN(currentHeight)
195
- ) {
196
- offsetsForActive.push(fallbackOffset);
197
- continue;
159
+ }
160
+ if (changed) {
161
+ hovered = next;
162
+ }
163
+ });
164
+
165
+ $effect(() => {
166
+ if (toasts.length === 0 && isToastRegionClaimed) {
167
+ isToastRegionClaimed = false;
168
+ focusManager.releaseClaim();
169
+ }
170
+ });
171
+
172
+ // Calculate collapsedOffsetData
173
+ $effect(() => {
174
+ const byPosition = createPositionMap<number[]>(() => []);
175
+ const byId: Record<string, number> = {};
176
+
177
+ for (const [pos, group] of positionEntries) {
178
+ const isTopPosition = pos.startsWith("top-");
179
+ const activeToasts = group.filter((toast) => !toast.shouldClose);
180
+ const offsetsForActive: number[] = [];
181
+
182
+ for (let i = 0; i < activeToasts.length; i++) {
183
+ if (i === 0) {
184
+ offsetsForActive.push(0);
185
+ continue;
186
+ }
187
+
188
+ const prevToast = activeToasts[i - 1];
189
+ const currentToast = activeToasts[i];
190
+ const prevOffset = offsetsForActive[i - 1] ?? 0;
191
+ if (!prevToast || !currentToast) {
192
+ offsetsForActive.push(prevOffset);
193
+ continue;
194
+ }
195
+ const prevHeight = heights[prevToast.id];
196
+ const currentHeight = heights[currentToast.id];
197
+ const fallbackOffset =
198
+ prevOffset + (isTopPosition ? 1 : -1) * ANIMATION_CONFIG.STACK_OFFSET;
199
+
200
+ if (
201
+ prevHeight == null ||
202
+ currentHeight == null ||
203
+ Number.isNaN(prevHeight) ||
204
+ Number.isNaN(currentHeight)
205
+ ) {
206
+ offsetsForActive.push(fallbackOffset);
207
+ continue;
208
+ }
209
+
210
+ if (isTopPosition) {
211
+ offsetsForActive.push(
212
+ prevOffset + (prevHeight - currentHeight + ANIMATION_CONFIG.STACK_OFFSET),
213
+ );
214
+ } else {
215
+ offsetsForActive.push(
216
+ prevOffset + (currentHeight - prevHeight - ANIMATION_CONFIG.STACK_OFFSET),
217
+ );
218
+ }
198
219
  }
199
220
 
200
- if (isTopPosition) {
201
- offsetsForActive.push(
202
- prevOffset +
203
- (prevHeight - currentHeight + ANIMATION_CONFIG.STACK_OFFSET),
204
- );
205
- } else {
206
- offsetsForActive.push(
207
- prevOffset +
208
- (currentHeight - prevHeight - ANIMATION_CONFIG.STACK_OFFSET),
209
- );
221
+ for (let i = 0; i < activeToasts.length; i++) {
222
+ const toast = activeToasts[i];
223
+ if (!toast) continue;
224
+ byId[toast.id] = offsetsForActive[i] ?? 0;
210
225
  }
211
- }
212
226
 
213
- for (let i = 0; i < activeToasts.length; i++) {
214
- const toast = activeToasts[i];
215
- if (!toast) continue;
216
- byId[toast.id] = offsetsForActive[i] ?? 0;
217
- }
227
+ for (const toast of group) {
228
+ if (byId[toast.id] != null) continue;
229
+ const previousOffset = previousCollapsedOffsets[toast.id];
230
+ if (typeof previousOffset === "number") {
231
+ byId[toast.id] = previousOffset;
232
+ continue;
233
+ }
218
234
 
219
- for (const toast of group) {
220
- if (byId[toast.id] != null) continue;
221
- const previousOffset = previousCollapsedOffsets[toast.id];
222
- if (typeof previousOffset === "number") {
223
- byId[toast.id] = previousOffset;
224
- continue;
235
+ const defaultOffset = isTopPosition
236
+ ? toast.index * ANIMATION_CONFIG.STACK_OFFSET
237
+ : -(toast.index * ANIMATION_CONFIG.STACK_OFFSET);
238
+ byId[toast.id] = defaultOffset;
225
239
  }
226
240
 
227
- const defaultOffset = isTopPosition
228
- ? toast.index * ANIMATION_CONFIG.STACK_OFFSET
229
- : -(toast.index * ANIMATION_CONFIG.STACK_OFFSET);
230
- byId[toast.id] = defaultOffset;
241
+ byPosition[pos] = group.map((toast) => byId[toast.id] ?? 0);
231
242
  }
232
243
 
233
- byPosition[pos] = group.map((toast) => byId[toast.id] ?? 0);
234
- }
244
+ previousCollapsedOffsets = byId;
245
+ collapsedOffsetData = { byPosition, byId };
246
+ });
235
247
 
236
- previousCollapsedOffsets = byId;
237
- collapsedOffsetData = { byPosition, byId };
238
- });
248
+ // Calculate expandedOffsetData
249
+ $effect(() => {
250
+ const byPosition = createPositionMap<number[]>(() => []);
251
+ const byId: Record<string, number> = {};
239
252
 
240
- // Calculate expandedOffsetData
241
- $effect(() => {
242
- const byPosition = createPositionMap<number[]>(() => []);
243
- const byId: Record<string, number> = {};
253
+ for (const [pos, group] of positionEntries) {
254
+ const offsets: number[] = [];
255
+ const activeToasts = group.filter((toast) => !toast.shouldClose);
256
+ let acc = 0;
244
257
 
245
- for (const [pos, group] of positionEntries) {
246
- const offsets: number[] = [];
247
- const activeToasts = group.filter((toast) => !toast.shouldClose);
248
- let acc = 0;
258
+ for (let i = 0; i < activeToasts.length; i++) {
259
+ if (i === 0) {
260
+ offsets.push(0);
261
+ continue;
262
+ }
263
+ const prevToast = activeToasts[i - 1];
264
+ const prevHeight = prevToast ? (heights[prevToast.id] ?? 0) : 0;
265
+ acc += prevHeight + expandedGap;
266
+ offsets.push(acc);
267
+ }
249
268
 
250
- for (let i = 0; i < activeToasts.length; i++) {
251
- if (i === 0) {
252
- offsets.push(0);
253
- continue;
269
+ for (let i = 0; i < activeToasts.length; i++) {
270
+ const toast = activeToasts[i];
271
+ if (!toast) continue;
272
+ byId[toast.id] = offsets[i] ?? 0;
254
273
  }
255
- const prevToast = activeToasts[i - 1];
256
- const prevHeight = prevToast ? (heights[prevToast.id] ?? 0) : 0;
257
- acc += prevHeight + expandedGap;
258
- offsets.push(acc);
259
- }
260
274
 
261
- for (let i = 0; i < activeToasts.length; i++) {
262
- const toast = activeToasts[i];
263
- if (!toast) continue;
264
- byId[toast.id] = offsets[i] ?? 0;
265
- }
275
+ for (const toast of group) {
276
+ if (byId[toast.id] != null) continue;
266
277
 
267
- for (const toast of group) {
268
- if (byId[toast.id] != null) continue;
278
+ const previousOffset = previousExpandedOffsets[toast.id];
279
+ if (typeof previousOffset === "number") {
280
+ byId[toast.id] = previousOffset;
281
+ continue;
282
+ }
269
283
 
270
- const previousOffset = previousExpandedOffsets[toast.id];
271
- if (typeof previousOffset === "number") {
272
- byId[toast.id] = previousOffset;
273
- continue;
284
+ let fallback = 0;
285
+ for (const candidate of group) {
286
+ if (candidate.id === toast.id) break;
287
+ const height = heights[candidate.id] ?? 0;
288
+ fallback += height + expandedGap;
289
+ }
290
+ byId[toast.id] = fallback;
274
291
  }
275
292
 
276
- let fallback = 0;
277
- for (const candidate of group) {
278
- if (candidate.id === toast.id) break;
279
- const height = heights[candidate.id] ?? 0;
280
- fallback += height + expandedGap;
281
- }
282
- byId[toast.id] = fallback;
293
+ byPosition[pos] = group.map((toast) => byId[toast.id] ?? 0);
283
294
  }
284
295
 
285
- byPosition[pos] = group.map((toast) => byId[toast.id] ?? 0);
286
- }
296
+ previousExpandedOffsets = byId;
297
+ expandedOffsetData = { byPosition, byId };
298
+ });
299
+
300
+ $effect(() => {
301
+ let hoverFrameId: number | null = null;
302
+ let pointerX = 0;
303
+ let pointerY = 0;
304
+
305
+ const updateHoverState = (x: number, y: number) => {
306
+ if (latestPositionEntries.length === 0) return;
307
+ const next: Record<ToastPosition, boolean> = {
308
+ ...latestHovered,
309
+ };
310
+ for (const [pos, group] of latestPositionEntries) {
311
+ let top = Number.POSITIVE_INFINITY;
312
+ let left = Number.POSITIVE_INFINITY;
313
+ let right = Number.NEGATIVE_INFINITY;
314
+ let bottom = Number.NEGATIVE_INFINITY;
315
+ let any = false;
316
+ for (const t of group) {
317
+ if (t.index >= visibleToasts) continue;
318
+ const el = document.querySelector(
319
+ `[data-toast-id="${t.id}"]`,
320
+ ) as HTMLElement | null;
321
+ if (!el) continue;
322
+ const rect = el.getBoundingClientRect();
323
+ top = Math.min(top, rect.top);
324
+ left = Math.min(left, rect.left);
325
+ right = Math.max(right, rect.right);
326
+ bottom = Math.max(bottom, rect.bottom);
327
+ any = true;
328
+ }
329
+
330
+ if (!any) {
331
+ next[pos] = false;
332
+ continue;
333
+ }
334
+
335
+ const inside = x >= left && x <= right && y >= top && y <= bottom;
336
+ next[pos] = inside;
337
+ }
287
338
 
288
- previousExpandedOffsets = byId;
289
- expandedOffsetData = { byPosition, byId };
290
- });
339
+ const changed = (Object.keys(next) as ToastPosition[]).some(
340
+ (key) => next[key] !== hovered[key],
341
+ );
342
+ if (changed) {
343
+ hovered = next;
344
+ }
345
+ };
291
346
 
292
- $effect(() => {
293
- let hoverFrameId: number | null = null;
294
- let pointerX = 0;
295
- let pointerY = 0;
347
+ const flushHoverUpdate = () => {
348
+ hoverFrameId = null;
349
+ updateHoverState(pointerX, pointerY);
350
+ };
296
351
 
297
- const updateHoverState = (x: number, y: number) => {
298
- if (latestPositionEntries.length === 0) return;
299
- const next: Record<ToastPosition, boolean> = {
300
- ...latestHovered,
352
+ const handleMouseMove = (event: MouseEvent) => {
353
+ pointerX = event.clientX;
354
+ pointerY = event.clientY;
355
+ if (hoverFrameId == null) {
356
+ hoverFrameId = requestAnimationFrame(flushHoverUpdate);
357
+ }
301
358
  };
302
- for (const [pos, group] of latestPositionEntries) {
303
- let top = Number.POSITIVE_INFINITY;
304
- let left = Number.POSITIVE_INFINITY;
305
- let right = Number.NEGATIVE_INFINITY;
306
- let bottom = Number.NEGATIVE_INFINITY;
307
- let any = false;
308
- for (const t of group) {
309
- if (t.index >= visibleToasts) continue;
310
- const el = document.querySelector(
311
- `[data-toast-id="${t.id}"]`,
312
- ) as HTMLElement | null;
313
- if (!el) continue;
314
- const rect = el.getBoundingClientRect();
315
- top = Math.min(top, rect.top);
316
- left = Math.min(left, rect.left);
317
- right = Math.max(right, rect.right);
318
- bottom = Math.max(bottom, rect.bottom);
319
- any = true;
359
+
360
+ const handleKeyDown = (event: KeyboardEvent) => {
361
+ if (event.key === "F6") {
362
+ if (latestPositionEntries.length === 0) return;
363
+ let target: HTMLElement | null = null;
364
+ for (const [, group] of latestPositionEntries) {
365
+ const candidate = group.find(
366
+ (t) => !t.shouldClose && !t.isLeaving && t.index < visibleToasts,
367
+ );
368
+ if (!candidate) continue;
369
+ const el = document.querySelector(
370
+ `[data-toast-id="${candidate.id}"]`,
371
+ ) as HTMLElement | null;
372
+ if (el) {
373
+ target = el;
374
+ break;
375
+ }
376
+ }
377
+ if (!target) return;
378
+ if (document.activeElement && target.contains(document.activeElement)) {
379
+ return;
380
+ }
381
+ event.preventDefault();
382
+ focusManager.savePrevFocus();
383
+ isToastRegionClaimed = true;
384
+ const focusable = target.querySelector<HTMLElement>(FOCUSABLE_SELECTORS);
385
+ (focusable ?? target).focus({ preventScroll: true });
386
+ return;
320
387
  }
321
388
 
322
- if (!any) {
323
- next[pos] = false;
324
- continue;
389
+ if (event.key !== "Escape") return;
390
+ for (const [, group] of latestPositionEntries) {
391
+ const latestToast = group?.[0];
392
+ if (!latestToast) continue;
393
+ const container = document.querySelector(
394
+ `[data-toast-id="${latestToast.id}"]`,
395
+ ) as HTMLElement | null;
396
+ if (!container) continue;
397
+ const active = document.activeElement as HTMLElement | null;
398
+ if (active && container.contains(active)) {
399
+ const closeBtn = container.querySelector(
400
+ '[aria-label="Close toast"]',
401
+ ) as HTMLButtonElement | null;
402
+ if (closeBtn) {
403
+ event.preventDefault();
404
+ closeBtn.click();
405
+ }
406
+ }
325
407
  }
408
+ };
326
409
 
327
- const inside = x >= left && x <= right && y >= top && y <= bottom;
328
- next[pos] = inside;
329
- }
330
410
 
331
- const changed = (Object.keys(next) as ToastPosition[]).some(
332
- (key) => next[key] !== hovered[key],
333
- );
334
- if (changed) {
335
- hovered = next;
336
- }
337
- };
411
+ const handleWindowBlur = () => {
412
+ isWindowFocused = false;
413
+ };
338
414
 
339
- const flushHoverUpdate = () => {
340
- hoverFrameId = null;
341
- updateHoverState(pointerX, pointerY);
342
- };
415
+ const handleWindowFocus = () => {
416
+ isWindowFocused = true;
417
+ };
343
418
 
344
- const handleMouseMove = (event: MouseEvent) => {
345
- pointerX = event.clientX;
346
- pointerY = event.clientY;
347
- if (hoverFrameId == null) {
348
- hoverFrameId = requestAnimationFrame(flushHoverUpdate);
349
- }
350
- };
419
+ const handleVisibilityChange = () => {
420
+ isWindowFocused = document.visibilityState !== "hidden";
421
+ };
351
422
 
352
- const handleKeyDown = (event: KeyboardEvent) => {
353
- if (event.key !== "Escape") return;
354
- for (const [, group] of latestPositionEntries) {
355
- const latestToast = group?.[0];
356
- if (!latestToast) continue;
357
- const container = document.querySelector(
358
- `[data-toast-id="${latestToast.id}"]`,
359
- ) as HTMLElement | null;
360
- if (!container) continue;
361
- const active = document.activeElement as HTMLElement | null;
362
- if (active && container.contains(active)) {
363
- const closeBtn = container.querySelector(
364
- '[aria-label="Close toast"]',
365
- ) as HTMLButtonElement | null;
366
- if (closeBtn) {
367
- event.preventDefault();
368
- closeBtn.click();
423
+ const handleDocumentPointerDown = (event: PointerEvent) => {
424
+ if (event.pointerType !== "touch") return;
425
+ if (latestPositionEntries.length === 0) return;
426
+ const target = event.target as Element | null;
427
+ if (target && target.closest("[data-toast-id]")) return;
428
+
429
+ let changed = false;
430
+ const next = { ...hovered };
431
+ for (const pos of Object.keys(hovered) as ToastPosition[]) {
432
+ if (next[pos]) {
433
+ next[pos] = false;
434
+ changed = true;
369
435
  }
370
436
  }
371
- }
437
+ if (changed) {
438
+ hovered = next;
439
+ }
440
+ };
441
+
442
+ document.addEventListener("mousemove", handleMouseMove);
443
+ document.addEventListener("keydown", handleKeyDown);
444
+ window.addEventListener("blur", handleWindowBlur);
445
+ window.addEventListener("focus", handleWindowFocus);
446
+ document.addEventListener("visibilitychange", handleVisibilityChange);
447
+ document.addEventListener("pointerdown", handleDocumentPointerDown, true);
448
+ return () => {
449
+ if (hoverFrameId != null) {
450
+ cancelAnimationFrame(hoverFrameId);
451
+ hoverFrameId = null;
452
+ }
453
+ document.removeEventListener("mousemove", handleMouseMove);
454
+ document.removeEventListener("keydown", handleKeyDown);
455
+ window.removeEventListener("blur", handleWindowBlur);
456
+ window.removeEventListener("focus", handleWindowFocus);
457
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
458
+ document.removeEventListener("pointerdown", handleDocumentPointerDown, true);
459
+ };
460
+ });
461
+
462
+ const handleHeightChange = (id: string, height: number) => {
463
+ if (heights[id] === height) return;
464
+ heights = { ...heights, [id]: height };
372
465
  };
373
466
 
374
- document.addEventListener("mousemove", handleMouseMove);
375
- document.addEventListener("keydown", handleKeyDown);
376
- return () => {
377
- if (hoverFrameId != null) {
378
- cancelAnimationFrame(hoverFrameId);
379
- hoverFrameId = null;
380
- }
381
- document.removeEventListener("mousemove", handleMouseMove);
382
- document.removeEventListener("keydown", handleKeyDown);
467
+ const handleFocusGuard = () => {
468
+ if (!isToastRegionClaimed) return;
469
+ isToastRegionClaimed = false;
470
+ focusManager.restoreFocusToPrevElement();
383
471
  };
384
- });
385
472
 
386
- const handleHeightChange = (id: string, height: number) => {
387
- if (heights[id] === height) return;
388
- heights = { ...heights, [id]: height };
389
- };
473
+ let highPriorityToasts = $derived(
474
+ toasts.filter(
475
+ (toast) =>
476
+ toast.priority === "high" &&
477
+ !toast.shouldClose &&
478
+ !toast.isLeaving &&
479
+ (toast.title || toast.description),
480
+ ),
481
+ );
390
482
  </script>
391
483
 
484
+ {#if highPriorityToasts.length > 0}
485
+ <div class="vs-sr-only" role="region" aria-label="High priority notifications">
486
+ {#each highPriorityToasts as toast (toast.id)}
487
+ <div role="alert" aria-atomic="true">
488
+ {#if toast.title}<span>{toast.title}</span>{/if}
489
+ {#if toast.title && toast.description}<span>: </span>{/if}
490
+ {#if toast.description}<span>{toast.description}</span>{/if}
491
+ </div>
492
+ {/each}
493
+ </div>
494
+ {/if}
495
+
392
496
  {#if toasts.length > 0}
393
- <div
394
- class="pointer-events-none fixed inset-0 z-50"
395
- {dir}
396
- >
397
- {#each positionEntries as [position, positionToasts]}
497
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
498
+ <div
499
+ class="vs-sr-only"
500
+ role="presentation"
501
+ tabindex={isToastRegionClaimed ? 0 : -1}
502
+ onfocus={handleFocusGuard}
503
+ ></div>
504
+ <div class="pointer-events-none fixed inset-0 z-50" {dir}>
505
+ {#each positionEntries as [position, positionToasts] (position)}
398
506
  {@const pos = position}
399
507
  {@const expandedOffsets = expandedOffsetData.byPosition[pos]}
400
508
  {@const collapsedOffsets = collapsedOffsetData.byPosition[pos]}
@@ -408,20 +516,18 @@ const handleHeightChange = (id: string, height: number) => {
408
516
  visibleStackLimit,
409
517
  )}
410
518
  {@const lastVisibleToastId = activeToasts[maxVisibleStackIndex]?.id}
411
- {@const lastVisibleRenderIndex = lastVisibleToastId != null
412
- ? positionToasts.findIndex(
413
- (candidate) => candidate.id === lastVisibleToastId,
414
- )
415
- : -1}
519
+ {@const lastVisibleRenderIndex =
520
+ lastVisibleToastId != null
521
+ ? positionToasts.findIndex((candidate) => candidate.id === lastVisibleToastId)
522
+ : -1}
416
523
  {@const sharedHiddenCollapsedOffset =
417
524
  lastVisibleRenderIndex >= 0
418
525
  ? collapsedOffsets?.[lastVisibleRenderIndex]
419
526
  : undefined}
420
527
  {#each positionToasts as toast, idx (toast.id)}
421
- {@const toastIsHidden =
422
- toast.index >= visibleToasts}
528
+ {@const toastIsHidden = toast.index >= visibleToasts}
423
529
  {@const hiddenCollapsedOffset = toastIsHidden
424
- ? sharedHiddenCollapsedOffset ?? collapsedOffsets?.[idx]
530
+ ? (sharedHiddenCollapsedOffset ?? collapsedOffsets?.[idx])
425
531
  : collapsedOffsets?.[idx]}
426
532
  {@const collapsedOffsetValue = collapsedOffsets?.[idx]}
427
533
  <VarselItem
@@ -434,18 +540,25 @@ const handleHeightChange = (id: string, height: number) => {
434
540
  onGroupHoverEnter={() => {
435
541
  hovered = { ...hovered, [pos]: true };
436
542
  }}
437
- onGroupHoldChange={(holding) =>
438
- updateHoldState(pos, toast.id, holding)}
543
+ onGroupHoldChange={(holding) => updateHoldState(pos, toast.id, holding)}
439
544
  collapsedOffset={collapsedOffsetValue}
440
- hiddenCollapsedOffset={hiddenCollapsedOffset}
545
+ {hiddenCollapsedOffset}
441
546
  defaultDuration={duration}
442
547
  defaultShowClose={closeButton}
443
548
  {pauseOnHover}
444
549
  {offset}
445
550
  {expand}
446
551
  {visibleToasts}
552
+ {isWindowFocused}
447
553
  />
448
554
  {/each}
449
555
  {/each}
450
556
  </div>
557
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
558
+ <div
559
+ class="vs-sr-only"
560
+ role="presentation"
561
+ tabindex={isToastRegionClaimed ? 0 : -1}
562
+ onfocus={handleFocusGuard}
563
+ ></div>
451
564
  {/if}