varsel 0.5.2 → 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,380 +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
+ }
287
334
 
288
- previousExpandedOffsets = byId;
289
- expandedOffsetData = { byPosition, byId };
290
- });
335
+ const inside = x >= left && x <= right && y >= top && y <= bottom;
336
+ next[pos] = inside;
337
+ }
291
338
 
292
- $effect(() => {
293
- const handleMouseMove = (event: MouseEvent) => {
294
- if (latestPositionEntries.length === 0) return;
295
- const { clientX: x, clientY: y } = event;
296
- const next: Record<ToastPosition, boolean> = {
297
- ...latestHovered,
339
+ const changed = (Object.keys(next) as ToastPosition[]).some(
340
+ (key) => next[key] !== hovered[key],
341
+ );
342
+ if (changed) {
343
+ hovered = next;
344
+ }
298
345
  };
299
- for (const [pos, group] of latestPositionEntries) {
300
- let top = Number.POSITIVE_INFINITY;
301
- let left = Number.POSITIVE_INFINITY;
302
- let right = Number.NEGATIVE_INFINITY;
303
- let bottom = Number.NEGATIVE_INFINITY;
304
- let any = false;
305
- for (const t of group) {
306
- if (t.index >= visibleToasts) continue;
307
- const el = document.querySelector(
308
- `[data-toast-id="${t.id}"]`,
309
- ) as HTMLElement | null;
310
- if (!el) continue;
311
- const rect = el.getBoundingClientRect();
312
- top = Math.min(top, rect.top);
313
- left = Math.min(left, rect.left);
314
- right = Math.max(right, rect.right);
315
- bottom = Math.max(bottom, rect.bottom);
316
- any = true;
346
+
347
+ const flushHoverUpdate = () => {
348
+ hoverFrameId = null;
349
+ updateHoverState(pointerX, pointerY);
350
+ };
351
+
352
+ const handleMouseMove = (event: MouseEvent) => {
353
+ pointerX = event.clientX;
354
+ pointerY = event.clientY;
355
+ if (hoverFrameId == null) {
356
+ hoverFrameId = requestAnimationFrame(flushHoverUpdate);
317
357
  }
358
+ };
318
359
 
319
- if (!any) {
320
- next[pos] = false;
321
- continue;
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;
322
387
  }
323
388
 
324
- const inside = x >= left && x <= right && y >= top && y <= bottom;
325
- next[pos] = inside;
326
- }
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
+ }
407
+ }
408
+ };
327
409
 
328
- const changed = (Object.keys(next) as ToastPosition[]).some(
329
- (key) => next[key] !== hovered[key],
330
- );
331
- if (changed) {
332
- hovered = next;
333
- }
334
- };
335
410
 
336
- const handleKeyDown = (event: KeyboardEvent) => {
337
- if (event.key !== "Escape") return;
338
- for (const [, group] of latestPositionEntries) {
339
- const latestToast = group?.[0];
340
- if (!latestToast) continue;
341
- const container = document.querySelector(
342
- `[data-toast-id="${latestToast.id}"]`,
343
- ) as HTMLElement | null;
344
- if (!container) continue;
345
- const active = document.activeElement as HTMLElement | null;
346
- if (active && container.contains(active)) {
347
- const closeBtn = container.querySelector(
348
- '[aria-label="Close toast"]',
349
- ) as HTMLButtonElement | null;
350
- if (closeBtn) {
351
- event.preventDefault();
352
- closeBtn.click();
411
+ const handleWindowBlur = () => {
412
+ isWindowFocused = false;
413
+ };
414
+
415
+ const handleWindowFocus = () => {
416
+ isWindowFocused = true;
417
+ };
418
+
419
+ const handleVisibilityChange = () => {
420
+ isWindowFocused = document.visibilityState !== "hidden";
421
+ };
422
+
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;
353
435
  }
354
436
  }
355
- }
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 };
356
465
  };
357
466
 
358
- document.addEventListener("mousemove", handleMouseMove);
359
- document.addEventListener("keydown", handleKeyDown);
360
- return () => {
361
- document.removeEventListener("mousemove", handleMouseMove);
362
- document.removeEventListener("keydown", handleKeyDown);
467
+ const handleFocusGuard = () => {
468
+ if (!isToastRegionClaimed) return;
469
+ isToastRegionClaimed = false;
470
+ focusManager.restoreFocusToPrevElement();
363
471
  };
364
- });
365
472
 
366
- const handleHeightChange = (id: string, height: number) => {
367
- if (heights[id] === height) return;
368
- heights = { ...heights, [id]: height };
369
- };
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
+ );
370
482
  </script>
371
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
+
372
496
  {#if toasts.length > 0}
373
- <div
374
- class="pointer-events-none fixed inset-0 z-50"
375
- {dir}
376
- >
377
- {#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)}
378
506
  {@const pos = position}
379
507
  {@const expandedOffsets = expandedOffsetData.byPosition[pos]}
380
508
  {@const collapsedOffsets = collapsedOffsetData.byPosition[pos]}
@@ -388,20 +516,18 @@ const handleHeightChange = (id: string, height: number) => {
388
516
  visibleStackLimit,
389
517
  )}
390
518
  {@const lastVisibleToastId = activeToasts[maxVisibleStackIndex]?.id}
391
- {@const lastVisibleRenderIndex = lastVisibleToastId != null
392
- ? positionToasts.findIndex(
393
- (candidate) => candidate.id === lastVisibleToastId,
394
- )
395
- : -1}
519
+ {@const lastVisibleRenderIndex =
520
+ lastVisibleToastId != null
521
+ ? positionToasts.findIndex((candidate) => candidate.id === lastVisibleToastId)
522
+ : -1}
396
523
  {@const sharedHiddenCollapsedOffset =
397
524
  lastVisibleRenderIndex >= 0
398
525
  ? collapsedOffsets?.[lastVisibleRenderIndex]
399
526
  : undefined}
400
527
  {#each positionToasts as toast, idx (toast.id)}
401
- {@const toastIsHidden =
402
- toast.index >= visibleToasts}
528
+ {@const toastIsHidden = toast.index >= visibleToasts}
403
529
  {@const hiddenCollapsedOffset = toastIsHidden
404
- ? sharedHiddenCollapsedOffset ?? collapsedOffsets?.[idx]
530
+ ? (sharedHiddenCollapsedOffset ?? collapsedOffsets?.[idx])
405
531
  : collapsedOffsets?.[idx]}
406
532
  {@const collapsedOffsetValue = collapsedOffsets?.[idx]}
407
533
  <VarselItem
@@ -414,18 +540,25 @@ const handleHeightChange = (id: string, height: number) => {
414
540
  onGroupHoverEnter={() => {
415
541
  hovered = { ...hovered, [pos]: true };
416
542
  }}
417
- onGroupHoldChange={(holding) =>
418
- updateHoldState(pos, toast.id, holding)}
543
+ onGroupHoldChange={(holding) => updateHoldState(pos, toast.id, holding)}
419
544
  collapsedOffset={collapsedOffsetValue}
420
- hiddenCollapsedOffset={hiddenCollapsedOffset}
545
+ {hiddenCollapsedOffset}
421
546
  defaultDuration={duration}
422
547
  defaultShowClose={closeButton}
423
548
  {pauseOnHover}
424
549
  {offset}
425
550
  {expand}
426
551
  {visibleToasts}
552
+ {isWindowFocused}
427
553
  />
428
554
  {/each}
429
555
  {/each}
430
556
  </div>
431
- {/if}
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>
564
+ {/if}