srcdev-nuxt-components 9.6.0 → 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude/skills/component-compliance-checklist.md +8 -2
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/container-glow.md +21 -8
  7. package/.claude/skills/components/display-avatar.md +13 -7
  8. package/.claude/skills/components/display-chip.md +37 -24
  9. package/.claude/skills/components/display-pill.md +58 -10
  10. package/.claude/skills/components/display-prompt.md +8 -5
  11. package/.claude/skills/components/display-toast.md +21 -4
  12. package/.claude/skills/components/glowing-border.md +20 -2
  13. package/.claude/skills/components/responsive-header.md +11 -1
  14. package/.claude/skills/components/select-menu.md +54 -5
  15. package/.claude/skills/components/site-header.md +2 -2
  16. package/.claude/skills/components/tabbed-content.md +300 -0
  17. package/.claude/skills/composable-anchor-scroll.md +16 -15
  18. package/.claude/skills/index.md +4 -4
  19. package/.claude/skills/storybook-add-story.md +53 -0
  20. package/.claude/skills/theming-status-tokens.md +60 -0
  21. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  22. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  23. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  24. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  25. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  26. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  27. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  28. package/app/assets/styles/setup/03.theming/index.css +1 -0
  29. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  30. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  31. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  32. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  33. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  34. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  35. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  36. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  37. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  38. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  39. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  40. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  41. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  42. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  43. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  44. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  45. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  46. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  47. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  48. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  49. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  50. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  51. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  52. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  53. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  54. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  55. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  56. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  57. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  58. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  59. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  60. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  61. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  62. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  63. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  64. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  65. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  66. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  67. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  68. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  69. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  70. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  71. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  72. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  73. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  74. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  75. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  76. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  77. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  78. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  79. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  80. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  81. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  82. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  83. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  84. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  85. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  86. package/app/composables/useTabs.ts +148 -206
  87. package/app/types/components/display-chip.d.ts +8 -4
  88. package/app/types/components/display-toast.d.ts +2 -0
  89. package/app/types/components/index.ts +0 -1
  90. package/app/types/components/select-menu.d.ts +2 -0
  91. package/app/utils/pausableTimeout.ts +41 -0
  92. package/nuxt.config.ts +1 -1
  93. package/package.json +1 -1
  94. package/.claude/skills/components/tab-navigation.md +0 -288
  95. package/.claude/skills/components/tabs-core.md +0 -187
  96. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  97. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  98. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  99. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  100. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  101. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  102. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  103. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  104. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  105. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  106. package/app/composables/useNavCollapse.ts +0 -86
  107. package/app/types/components/nav-item.d.ts +0 -11
@@ -140,3 +140,76 @@ FullWidth.args = {
140
140
  theme: "warning",
141
141
  autoDismiss: false,
142
142
  };
143
+
144
+ const longUnbroken = "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft";
145
+
146
+ const stressToasts = [
147
+ {
148
+ theme: "error" as const,
149
+ title: "Ihre Zahlung konnte leider nicht verarbeitet werden, bitte überprüfen Sie Ihre Kartendaten",
150
+ description:
151
+ "Die Bank hat die Transaktion abgelehnt. Bitte versuchen Sie es mit einer anderen Zahlungsmethode erneut oder wenden Sie sich an Ihre Bank, um weitere Informationen zu erhalten. ".repeat(
152
+ 2
153
+ ),
154
+ },
155
+ { theme: "warning" as const, title: longUnbroken, description: "https://example.com/" + "a-long-path-segment".repeat(6) },
156
+ { theme: "success" as const, title: "🎉 Emoji first", description: "<b>Not bold</b> <script>alert('x')</script>" },
157
+ { theme: "info" as const, title: "تم حفظ التغييرات بنجاح", description: "نص عربي طويل من اليمين إلى اليسار للتحقق من الاتجاه" },
158
+ { theme: "info" as const, title: "A", description: "" },
159
+ { theme: "info" as const, title: "", description: "Description only, no title." },
160
+ ];
161
+
162
+ export const StressTest: StoryFn<StoryArgs> = (args) => ({
163
+ components: { DisplayToastProvider },
164
+ setup() {
165
+ const { show, clear } = useToastQueue();
166
+ const queueStress = () => {
167
+ stressToasts.forEach(({ theme, title, description }) => {
168
+ show({
169
+ appearance: { theme },
170
+ behavior: { autoDismiss: args.autoDismiss, duration: args.duration },
171
+ content: { title, description, dismissLabel: "Benachrichtigung schließen" },
172
+ });
173
+ });
174
+ };
175
+ const flood = () => {
176
+ for (let index = 1; index <= 20; index++) {
177
+ show({ behavior: { autoDismiss: true, duration: 1500 }, content: { title: `Toast ${index} of 20` } });
178
+ }
179
+ };
180
+ return { args, queueStress, flood, clear };
181
+ },
182
+ template: `
183
+ <div style="padding: 2rem; min-height: 200px;">
184
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
185
+ <button @click="queueStress" style="padding: 0.6rem 1.4rem; cursor: pointer; border: 1px solid #ccc; border-radius: 4px;">Queue worst-case toasts</button>
186
+ <button @click="flood" style="padding: 0.6rem 1.4rem; cursor: pointer; border: 1px solid #ccc; border-radius: 4px;">Flood 20 toasts</button>
187
+ <button @click="clear" style="padding: 0.6rem 1.4rem; cursor: pointer; border: 1px solid #ccc; border-radius: 4px;">Clear Queue</button>
188
+ </div>
189
+ <DisplayToastProvider
190
+ :position="args.position"
191
+ :alignment="args.alignment"
192
+ :full-width="args.fullWidth"
193
+ :max-visible="args.maxVisible"
194
+ />
195
+ </div>
196
+ `,
197
+ });
198
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
199
+ StressTest.args = {
200
+ maxVisible: 5,
201
+ autoDismiss: true,
202
+ duration: 8000,
203
+ };
204
+ StressTest.parameters = {
205
+ docs: {
206
+ description: {
207
+ story:
208
+ "Hostile toast content: long German copy, an unbroken word as the title, a long URL, emoji first, HTML-like text " +
209
+ "(must render as text), RTL, a one-character title, empty title and empty description, plus a flood of 20 queued toasts. " +
210
+ "Toasts are fixed to the viewport, so use Storybook's viewport toolbar (not a canvas width) to check narrow screens. " +
211
+ "Check that every toast stays inside the viewport and caps at 48rem on wide screens, unbroken text wraps, hovering a toast " +
212
+ "or tabbing into it freezes its progress bar and timer, and the flood drains in order without stacking past maxVisible.",
213
+ },
214
+ },
215
+ };
@@ -10,6 +10,9 @@ const { useAppConfigMock } = vi.hoisted(() => ({
10
10
 
11
11
  mockNuxtImport("useAppConfig", () => useAppConfigMock);
12
12
 
13
+ const mounted: { unmount: () => void }[] = [];
14
+ let focusVisibleSpy: { mockRestore: () => void } | null = null;
15
+
13
16
  // Helper: mount the toast and activate it so the teleported element is in the DOM.
14
17
  async function mountAndShow(props: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
15
18
  const { config, styleClassPassthrough, ...rest } = props;
@@ -17,6 +20,7 @@ async function mountAndShow(props: Record<string, unknown> = {}, slots: Record<s
17
20
  props: { config, styleClassPassthrough } as Record<string, unknown>,
18
21
  slots,
19
22
  });
23
+ mounted.push(wrapper);
20
24
  await wrapper.setProps({ ...rest, modelValue: true });
21
25
  await nextTick();
22
26
  return wrapper;
@@ -27,7 +31,11 @@ function toast() {
27
31
  }
28
32
 
29
33
  describe("DisplayToast", () => {
34
+ // Unmount first: a leftover toast's pending timers would otherwise re-render into the cleared body.
30
35
  afterEach(() => {
36
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
37
+ focusVisibleSpy?.mockRestore();
38
+ focusVisibleSpy = null;
31
39
  document.body.innerHTML = "";
32
40
  });
33
41
 
@@ -50,9 +58,9 @@ describe("DisplayToast", () => {
50
58
  expect(toast()).not.toBeNull();
51
59
  });
52
60
 
53
- it("has the show class when activated", async () => {
61
+ it("has data-state='show' when activated", async () => {
54
62
  await mountAndShow();
55
- expect(toast()!.classList).toContain("show");
63
+ expect(toast()!.getAttribute("data-state")).toBe("show");
56
64
  });
57
65
 
58
66
  // ─── data-theme ───────────────────────────────────────────────────────────
@@ -83,26 +91,28 @@ describe("DisplayToast", () => {
83
91
  expect(toast()!.getAttribute("aria-live")).toBe(live);
84
92
  });
85
93
 
86
- // ─── Position classes ─────────────────────────────────────────────────────
94
+ // ─── Position hooks ───────────────────────────────────────────────────────
87
95
 
88
- it.each(["top", "bottom"] as const)("applies %s position class", async (position) => {
96
+ it.each(["top", "bottom"] as const)("sets data-position='%s'", async (position) => {
89
97
  await mountAndShow({ config: { appearance: { position } } });
90
- expect(toast()!.classList).toContain(position);
98
+ expect(toast()!.getAttribute("data-position")).toBe(position);
91
99
  });
92
100
 
93
- it.each(["left", "center", "right"] as const)("applies %s alignment class", async (alignment) => {
101
+ it.each(["left", "center", "right"] as const)("sets data-alignment='%s'", async (alignment) => {
94
102
  await mountAndShow({ config: { appearance: { alignment } } });
95
- expect(toast()!.classList).toContain(alignment);
103
+ expect(toast()!.getAttribute("data-alignment")).toBe(alignment);
96
104
  });
97
105
 
98
- it("applies full-width class when fullWidth is true", async () => {
99
- await mountAndShow({ config: { appearance: { fullWidth: true } } });
100
- expect(toast()!.classList).toContain("full-width");
106
+ it("sets data-alignment='full-width' when fullWidth is true, overriding alignment", async () => {
107
+ await mountAndShow({ config: { appearance: { fullWidth: true, alignment: "left" } } });
108
+ expect(toast()!.getAttribute("data-alignment")).toBe("full-width");
101
109
  });
102
110
 
103
- it("does not apply alignment class when fullWidth is true", async () => {
104
- await mountAndShow({ config: { appearance: { fullWidth: true, alignment: "left" } } });
105
- expect(toast()!.classList).not.toContain("left");
111
+ it("adds no generic position or state classes a consumer's CSS could match", async () => {
112
+ await mountAndShow({ config: { appearance: { position: "top", alignment: "center" } } });
113
+ for (const name of ["top", "bottom", "left", "center", "right", "show", "hide", "full-width"]) {
114
+ expect(toast()!.classList).not.toContain(name);
115
+ }
106
116
  });
107
117
 
108
118
  // ─── tabindex / aria-describedby ──────────────────────────────────────────
@@ -194,7 +204,7 @@ describe("DisplayToast", () => {
194
204
  await nextTick();
195
205
  toast()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
196
206
  await nextTick();
197
- expect(toast()!.classList).toContain("hide");
207
+ expect(toast()!.getAttribute("data-state")).toBe("hide");
198
208
  });
199
209
 
200
210
  // ─── Dismiss via close button ─────────────────────────────────────────────
@@ -205,7 +215,111 @@ describe("DisplayToast", () => {
205
215
  await nextTick();
206
216
  (document.querySelector("[data-test-id='alert-dismiss']") as HTMLElement).click();
207
217
  await nextTick();
208
- expect(toast()!.classList).toContain("hide");
218
+ expect(toast()!.getAttribute("data-state")).toBe("hide");
219
+ });
220
+
221
+ // ─── Dismiss label ────────────────────────────────────────────────────────
222
+
223
+ it("passes config.content.dismissLabel to the close button's screen-reader text", async () => {
224
+ await mountAndShow({ config: { behavior: { autoDismiss: false }, content: { dismissLabel: "Schließen" } } });
225
+ expect(document.querySelector("[data-test-id='alert-dismiss']")!.textContent).toContain("Schließen");
226
+ });
227
+
228
+ // ─── Auto-dismiss pause ──────────────────────────────────────────────────
229
+
230
+ const autoConfig = { behavior: { autoDismiss: true, duration: 1000 } };
231
+
232
+ // jsdom matches :focus-visible whenever an element is focused; real browsers only do after keyboard input.
233
+ const originalMatches = Element.prototype.matches;
234
+ function setFocusVisible(keyboard: boolean) {
235
+ const spy = vi.spyOn(Element.prototype, "matches");
236
+ spy.mockImplementation(function (this: Element, selector: string) {
237
+ return selector === ":focus-visible" ? keyboard : originalMatches.call(this, selector);
238
+ });
239
+ focusVisibleSpy = spy;
240
+ }
241
+
242
+ it("auto-dismisses after the duration", async () => {
243
+ await mountAndShow({ config: autoConfig });
244
+ vi.advanceTimersByTime(1000);
245
+ await nextTick();
246
+ expect(toast()!.getAttribute("data-state")).toBe("hide");
247
+ });
248
+
249
+ it("pauses auto-dismiss while hovered and resumes with the remaining time", async () => {
250
+ setFocusVisible(false);
251
+ await mountAndShow({ config: autoConfig });
252
+ vi.advanceTimersByTime(400);
253
+ toast()!.dispatchEvent(new Event("pointerenter"));
254
+ await nextTick();
255
+ expect(toast()!.hasAttribute("data-paused")).toBe(true);
256
+ vi.advanceTimersByTime(5000);
257
+ await nextTick();
258
+ expect(toast()!.getAttribute("data-state")).toBe("show");
259
+ toast()!.dispatchEvent(new Event("pointerleave"));
260
+ await nextTick();
261
+ expect(toast()!.hasAttribute("data-paused")).toBe(false);
262
+ vi.advanceTimersByTime(599);
263
+ await nextTick();
264
+ expect(toast()!.getAttribute("data-state")).toBe("show");
265
+ vi.advanceTimersByTime(1);
266
+ await nextTick();
267
+ expect(toast()!.getAttribute("data-state")).toBe("hide");
268
+ });
269
+
270
+ it("pauses while focus is on something inside the toast", async () => {
271
+ await mountAndShow({ config: autoConfig }, { default: "<a href='#' class='inner-link'>Undo</a>" });
272
+ const link = document.querySelector(".inner-link") as HTMLElement;
273
+ link.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
274
+ await nextTick();
275
+ vi.advanceTimersByTime(5000);
276
+ await nextTick();
277
+ expect(toast()!.getAttribute("data-state")).toBe("show");
278
+ link.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: null }));
279
+ await nextTick();
280
+ vi.advanceTimersByTime(1000);
281
+ await nextTick();
282
+ expect(toast()!.getAttribute("data-state")).toBe("hide");
283
+ });
284
+
285
+ it("does not pause when the toast takes focus after mouse input", async () => {
286
+ setFocusVisible(false);
287
+ await mountAndShow({ config: autoConfig });
288
+ vi.advanceTimersByTime(100); // the toast focuses itself
289
+ await nextTick();
290
+ expect(document.activeElement).toBe(toast());
291
+ expect(toast()!.hasAttribute("data-paused")).toBe(false);
292
+ vi.advanceTimersByTime(900);
293
+ await nextTick();
294
+ expect(toast()!.getAttribute("data-state")).toBe("hide");
295
+ });
296
+
297
+ it("pauses when the toast takes focus after keyboard input, until focus leaves", async () => {
298
+ setFocusVisible(true);
299
+ await mountAndShow({ config: autoConfig });
300
+ vi.advanceTimersByTime(100);
301
+ await nextTick();
302
+ expect(toast()!.hasAttribute("data-paused")).toBe(true);
303
+ vi.advanceTimersByTime(5000);
304
+ await nextTick();
305
+ expect(toast()!.getAttribute("data-state")).toBe("show");
306
+ (toast() as HTMLElement).blur();
307
+ await nextTick();
308
+ vi.advanceTimersByTime(900);
309
+ await nextTick();
310
+ expect(toast()!.getAttribute("data-state")).toBe("hide");
311
+ });
312
+
313
+ it("does not let an earlier show's timer dismiss a toast re-shown after a manual dismiss", async () => {
314
+ const wrapper = await mountAndShow({ config: autoConfig });
315
+ await wrapper.setProps({ modelValue: false });
316
+ vi.advanceTimersByTime(550);
317
+ await nextTick();
318
+ await wrapper.setProps({ modelValue: true });
319
+ await nextTick();
320
+ vi.advanceTimersByTime(600);
321
+ await nextTick();
322
+ expect(toast()!.getAttribute("data-state")).toBe("show");
209
323
  });
210
324
 
211
325
  // ─── styleClassPassthrough ────────────────────────────────────────────────
@@ -236,8 +350,8 @@ describe("DisplayToast", () => {
236
350
  it("uses hardcoded fallbacks when app.config has no displayToast key", async () => {
237
351
  await mountAndShow();
238
352
  expect(toast()!.getAttribute("data-theme")).toBe("info");
239
- expect(toast()!.classList).toContain("top");
240
- expect(toast()!.classList).toContain("right");
353
+ expect(toast()!.getAttribute("data-position")).toBe("top");
354
+ expect(toast()!.getAttribute("data-alignment")).toBe("right");
241
355
  expect(document.querySelector(".display-toast-progress")).not.toBeNull();
242
356
  });
243
357
 
@@ -260,7 +374,7 @@ describe("DisplayToast", () => {
260
374
  }));
261
375
  await mountAndShow({ config: { appearance: { theme: "warning", position: "top" } } });
262
376
  expect(toast()!.getAttribute("data-theme")).toBe("warning");
263
- expect(toast()!.classList).toContain("top");
377
+ expect(toast()!.getAttribute("data-position")).toBe("top");
264
378
  });
265
379
  });
266
380
  });
@@ -4,6 +4,15 @@ import { mountSuspended } from "@nuxt/test-utils/runtime";
4
4
  import DisplayToastProvider from "../DisplayToastProvider.vue";
5
5
  import { useToastQueue } from "~/composables/useToastQueue";
6
6
 
7
+ const mounted: { unmount: () => void }[] = [];
8
+
9
+ // Every provider shares the global queue, so a leftover one from an earlier test would run its own timers.
10
+ async function mountProvider(...args: Parameters<typeof mountSuspended<typeof DisplayToastProvider>>) {
11
+ const wrapper = await mountSuspended(...args);
12
+ mounted.push(wrapper);
13
+ return wrapper;
14
+ }
15
+
7
16
  function item() {
8
17
  return document.querySelector(".display-toast-provider-item");
9
18
  }
@@ -24,6 +33,7 @@ describe("DisplayToastProvider", () => {
24
33
  });
25
34
 
26
35
  afterEach(() => {
36
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
27
37
  clear();
28
38
  document.body.innerHTML = "";
29
39
  });
@@ -31,47 +41,47 @@ describe("DisplayToastProvider", () => {
31
41
  // ─── Mount ────────────────────────────────────────────────────────────────
32
42
 
33
43
  it("mounts without error", async () => {
34
- const wrapper = await mountSuspended(DisplayToastProvider);
44
+ const wrapper = await mountProvider(DisplayToastProvider);
35
45
  expect(wrapper.vm).toBeTruthy();
36
46
  });
37
47
 
38
48
  it("renders the provider container", async () => {
39
- await mountSuspended(DisplayToastProvider);
49
+ await mountProvider(DisplayToastProvider);
40
50
  expect(provider()).not.toBeNull();
41
51
  });
42
52
 
43
53
  // ─── Empty state ──────────────────────────────────────────────────────────
44
54
 
45
55
  it("renders no items when queue is empty", async () => {
46
- await mountSuspended(DisplayToastProvider);
56
+ await mountProvider(DisplayToastProvider);
47
57
  expect(items().length).toBe(0);
48
58
  });
49
59
 
50
60
  // ─── Show / queue ─────────────────────────────────────────────────────────
51
61
 
52
62
  it("promotes a pending entry to visible on show()", async () => {
53
- await mountSuspended(DisplayToastProvider);
63
+ await mountProvider(DisplayToastProvider);
54
64
  show({ content: { text: "Hello" } });
55
65
  await nextTick();
56
66
  expect(items().length).toBe(1);
57
67
  });
58
68
 
59
69
  it("renders the toast message text", async () => {
60
- await mountSuspended(DisplayToastProvider);
70
+ await mountProvider(DisplayToastProvider);
61
71
  show({ content: { text: "Toast message" } });
62
72
  await nextTick();
63
73
  expect(document.querySelector(".alert-content-body")!.textContent).toContain("Toast message");
64
74
  });
65
75
 
66
76
  it("renders the toast title", async () => {
67
- await mountSuspended(DisplayToastProvider);
77
+ await mountProvider(DisplayToastProvider);
68
78
  show({ content: { title: "My Title" } });
69
79
  await nextTick();
70
80
  expect(document.querySelector("[data-test-id='alert-title']")!.textContent).toContain("My Title");
71
81
  });
72
82
 
73
83
  it("renders the toast description", async () => {
74
- await mountSuspended(DisplayToastProvider);
84
+ await mountProvider(DisplayToastProvider);
75
85
  show({ content: { description: "My description" } });
76
86
  await nextTick();
77
87
  expect(document.querySelector("[data-test-id='alert-content']")!.textContent).toContain("My description");
@@ -82,7 +92,7 @@ describe("DisplayToastProvider", () => {
82
92
  it.each(["info", "success", "warning", "error"] as const)(
83
93
  "sets data-theme='%s' from config",
84
94
  async (theme) => {
85
- await mountSuspended(DisplayToastProvider);
95
+ await mountProvider(DisplayToastProvider);
86
96
  show({ appearance: { theme } });
87
97
  await nextTick();
88
98
  expect(item()!.getAttribute("data-theme")).toBe(theme);
@@ -90,7 +100,7 @@ describe("DisplayToastProvider", () => {
90
100
  );
91
101
 
92
102
  it("defaults to data-theme='info'", async () => {
93
- await mountSuspended(DisplayToastProvider);
103
+ await mountProvider(DisplayToastProvider);
94
104
  show({});
95
105
  await nextTick();
96
106
  expect(item()!.getAttribute("data-theme")).toBe("info");
@@ -104,7 +114,7 @@ describe("DisplayToastProvider", () => {
104
114
  { theme: "warning" as const, role: "alert", live: "assertive" },
105
115
  { theme: "error" as const, role: "alert", live: "assertive" },
106
116
  ])("sets role=$role and aria-live=$live for theme='$theme'", async ({ theme, role, live }) => {
107
- await mountSuspended(DisplayToastProvider);
117
+ await mountProvider(DisplayToastProvider);
108
118
  show({ appearance: { theme } });
109
119
  await nextTick();
110
120
  expect(item()!.getAttribute("role")).toBe(role);
@@ -112,48 +122,56 @@ describe("DisplayToastProvider", () => {
112
122
  });
113
123
 
114
124
  it("sets aria-describedby on each item", async () => {
115
- await mountSuspended(DisplayToastProvider);
125
+ await mountProvider(DisplayToastProvider);
116
126
  show({});
117
127
  await nextTick();
118
128
  expect(item()!.getAttribute("aria-describedby")).not.toBeNull();
119
129
  });
120
130
 
121
131
  it("sets tabindex='0' on each item", async () => {
122
- await mountSuspended(DisplayToastProvider);
132
+ await mountProvider(DisplayToastProvider);
123
133
  show({});
124
134
  await nextTick();
125
135
  expect(item()!.getAttribute("tabindex")).toBe("0");
126
136
  });
127
137
 
128
- // ─── Position / alignment classes ────────────────────────────────────────
138
+ // ─── Position / alignment hooks ──────────────────────────────────────────
129
139
 
130
- it.each(["top", "bottom"] as const)("applies %s class to provider", async (position) => {
131
- await mountSuspended(DisplayToastProvider, { props: { position } });
132
- expect(provider()!.classList).toContain(position);
140
+ it.each(["top", "bottom"] as const)("sets data-position='%s' on the provider", async (position) => {
141
+ await mountProvider(DisplayToastProvider, { props: { position } });
142
+ expect(provider()!.getAttribute("data-position")).toBe(position);
133
143
  });
134
144
 
135
- it.each(["left", "center", "right"] as const)(
136
- "applies %s alignment class to provider",
137
- async (alignment) => {
138
- await mountSuspended(DisplayToastProvider, { props: { alignment } });
139
- expect(provider()!.classList).toContain(alignment);
140
- }
141
- );
145
+ it.each(["left", "center", "right"] as const)("sets data-alignment='%s' on the provider", async (alignment) => {
146
+ await mountProvider(DisplayToastProvider, { props: { alignment } });
147
+ expect(provider()!.getAttribute("data-alignment")).toBe(alignment);
148
+ });
149
+
150
+ it("sets data-alignment='full-width' when fullWidth is true, overriding alignment", async () => {
151
+ await mountProvider(DisplayToastProvider, { props: { fullWidth: true, alignment: "left" } });
152
+ expect(provider()!.getAttribute("data-alignment")).toBe("full-width");
153
+ });
142
154
 
143
- it("applies full-width class when fullWidth is true", async () => {
144
- await mountSuspended(DisplayToastProvider, { props: { fullWidth: true } });
145
- expect(provider()!.classList).toContain("full-width");
155
+ it("adds no generic position classes a consumer's CSS could match", async () => {
156
+ await mountProvider(DisplayToastProvider, { props: { position: "top", alignment: "center" } });
157
+ for (const name of ["top", "bottom", "left", "center", "right", "full-width"]) {
158
+ expect(provider()!.classList).not.toContain(name);
159
+ }
146
160
  });
147
161
 
148
- it("does not apply alignment class when fullWidth is true", async () => {
149
- await mountSuspended(DisplayToastProvider, { props: { fullWidth: true, alignment: "left" } });
150
- expect(provider()!.classList).not.toContain("left");
162
+ // ─── Dismiss label ────────────────────────────────────────────────────────
163
+
164
+ it("passes config.content.dismissLabel to the close button's screen-reader text", async () => {
165
+ await mountProvider(DisplayToastProvider);
166
+ show({ behavior: { autoDismiss: false }, content: { text: "Saved", dismissLabel: "Fermer" } });
167
+ await nextTick();
168
+ expect(document.querySelector("[data-test-id='alert-dismiss']")!.textContent).toContain("Fermer");
151
169
  });
152
170
 
153
171
  // ─── maxVisible ───────────────────────────────────────────────────────────
154
172
 
155
173
  it("shows only one item at a time when maxVisible=1", async () => {
156
- await mountSuspended(DisplayToastProvider, { props: { maxVisible: 1 } });
174
+ await mountProvider(DisplayToastProvider, { props: { maxVisible: 1 } });
157
175
  show({ content: { text: "First" } });
158
176
  show({ content: { text: "Second" } });
159
177
  await nextTick();
@@ -162,7 +180,7 @@ describe("DisplayToastProvider", () => {
162
180
  });
163
181
 
164
182
  it("shows up to maxVisible items simultaneously", async () => {
165
- await mountSuspended(DisplayToastProvider, { props: { maxVisible: 2 } });
183
+ await mountProvider(DisplayToastProvider, { props: { maxVisible: 2 } });
166
184
  show({ content: { text: "First" } });
167
185
  show({ content: { text: "Second" } });
168
186
  show({ content: { text: "Third" } });
@@ -173,7 +191,7 @@ describe("DisplayToastProvider", () => {
173
191
  // ─── Dismiss ──────────────────────────────────────────────────────────────
174
192
 
175
193
  it("removes item when close button is clicked", async () => {
176
- await mountSuspended(DisplayToastProvider);
194
+ await mountProvider(DisplayToastProvider);
177
195
  show({ behavior: { autoDismiss: false } });
178
196
  await nextTick();
179
197
  (document.querySelector("[data-test-id='alert-dismiss']") as HTMLElement).click();
@@ -182,7 +200,7 @@ describe("DisplayToastProvider", () => {
182
200
  });
183
201
 
184
202
  it("removes item when Escape is pressed", async () => {
185
- await mountSuspended(DisplayToastProvider);
203
+ await mountProvider(DisplayToastProvider);
186
204
  show({ behavior: { autoDismiss: false } });
187
205
  await nextTick();
188
206
  item()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
@@ -193,7 +211,7 @@ describe("DisplayToastProvider", () => {
193
211
  // ─── Auto-dismiss timer ───────────────────────────────────────────────────
194
212
 
195
213
  it("auto-dismisses after duration", async () => {
196
- await mountSuspended(DisplayToastProvider);
214
+ await mountProvider(DisplayToastProvider);
197
215
  show({ behavior: { autoDismiss: true, duration: 3000 } });
198
216
  await nextTick();
199
217
  expect(items().length).toBe(1);
@@ -202,8 +220,56 @@ describe("DisplayToastProvider", () => {
202
220
  expect(items().length).toBe(0);
203
221
  });
204
222
 
223
+ it("pauses auto-dismiss while hovered and resumes with the remaining time", async () => {
224
+ await mountProvider(DisplayToastProvider);
225
+ show({ behavior: { autoDismiss: true, duration: 1000 } });
226
+ await nextTick();
227
+ vi.advanceTimersByTime(400);
228
+ item()!.dispatchEvent(new Event("pointerenter"));
229
+ await nextTick();
230
+ expect(item()!.hasAttribute("data-paused")).toBe(true);
231
+ vi.advanceTimersByTime(5000);
232
+ await nextTick();
233
+ expect(items().length).toBe(1);
234
+ item()!.dispatchEvent(new Event("pointerleave"));
235
+ await nextTick();
236
+ vi.advanceTimersByTime(599);
237
+ await nextTick();
238
+ expect(items().length).toBe(1);
239
+ vi.advanceTimersByTime(1);
240
+ await nextTick();
241
+ expect(items().length).toBe(0);
242
+ });
243
+
244
+ it("pauses while focus is on something inside a toast, and resumes when it leaves", async () => {
245
+ await mountProvider(DisplayToastProvider);
246
+ show({ behavior: { autoDismiss: true, duration: 1000 } });
247
+ await nextTick();
248
+ const inner = item()!.querySelector(".alert-content-body") as HTMLElement;
249
+ inner.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
250
+ await nextTick();
251
+ expect(item()!.hasAttribute("data-paused")).toBe(true);
252
+ vi.advanceTimersByTime(5000);
253
+ await nextTick();
254
+ expect(items().length).toBe(1);
255
+ inner.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: null }));
256
+ await nextTick();
257
+ vi.advanceTimersByTime(1000);
258
+ await nextTick();
259
+ expect(items().length).toBe(0);
260
+ });
261
+
262
+ it("does not mark a non-auto-dismiss toast as paused", async () => {
263
+ await mountProvider(DisplayToastProvider);
264
+ show({ behavior: { autoDismiss: false } });
265
+ await nextTick();
266
+ item()!.dispatchEvent(new Event("pointerenter"));
267
+ await nextTick();
268
+ expect(item()!.hasAttribute("data-paused")).toBe(false);
269
+ });
270
+
205
271
  it("does not auto-dismiss when autoDismiss is false", async () => {
206
- await mountSuspended(DisplayToastProvider);
272
+ await mountProvider(DisplayToastProvider);
207
273
  show({ behavior: { autoDismiss: false } });
208
274
  await nextTick();
209
275
  vi.advanceTimersByTime(10000);
@@ -214,7 +280,7 @@ describe("DisplayToastProvider", () => {
214
280
  // ─── Queue progression ────────────────────────────────────────────────────
215
281
 
216
282
  it("promotes next pending entry after current is dismissed", async () => {
217
- await mountSuspended(DisplayToastProvider, { props: { maxVisible: 1 } });
283
+ await mountProvider(DisplayToastProvider, { props: { maxVisible: 1 } });
218
284
  show({ content: { text: "First" }, behavior: { autoDismiss: false } });
219
285
  show({ content: { text: "Second" }, behavior: { autoDismiss: false } });
220
286
  await nextTick();
@@ -228,14 +294,14 @@ describe("DisplayToastProvider", () => {
228
294
  // ─── Progress bar ─────────────────────────────────────────────────────────
229
295
 
230
296
  it("renders progress bar when autoDismiss is true", async () => {
231
- await mountSuspended(DisplayToastProvider);
297
+ await mountProvider(DisplayToastProvider);
232
298
  show({ behavior: { autoDismiss: true } });
233
299
  await nextTick();
234
300
  expect(document.querySelector(".display-toast-provider-progress")).not.toBeNull();
235
301
  });
236
302
 
237
303
  it("does not render progress bar when autoDismiss is false", async () => {
238
- await mountSuspended(DisplayToastProvider);
304
+ await mountProvider(DisplayToastProvider);
239
305
  show({ behavior: { autoDismiss: false } });
240
306
  await nextTick();
241
307
  expect(document.querySelector(".display-toast-provider-progress")).toBeNull();
@@ -245,7 +311,7 @@ describe("DisplayToastProvider", () => {
245
311
 
246
312
  it("removes all items on clear()", async () => {
247
313
  const { clear: clearQueue } = useToastQueue();
248
- await mountSuspended(DisplayToastProvider, { props: { maxVisible: 3 } });
314
+ await mountProvider(DisplayToastProvider, { props: { maxVisible: 3 } });
249
315
  show({});
250
316
  show({});
251
317
  await nextTick();