srcdev-nuxt-components 9.5.7 → 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 (159) 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/breadcrumb.md +5 -0
  7. package/.claude/skills/components/container-glow.md +21 -8
  8. package/.claude/skills/components/display-avatar.md +13 -7
  9. package/.claude/skills/components/display-chip.md +37 -24
  10. package/.claude/skills/components/display-pill.md +58 -8
  11. package/.claude/skills/components/display-prompt.md +8 -5
  12. package/.claude/skills/components/display-toast.md +21 -4
  13. package/.claude/skills/components/glowing-border.md +20 -2
  14. package/.claude/skills/components/input-button.md +3 -0
  15. package/.claude/skills/components/responsive-header.md +59 -0
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/service-detail.md +32 -0
  18. package/.claude/skills/components/service-summary.md +8 -0
  19. package/.claude/skills/components/services-card-grid.md +5 -3
  20. package/.claude/skills/components/services-card.md +22 -12
  21. package/.claude/skills/components/site-header.md +2 -2
  22. package/.claude/skills/components/slider-gallery.md +10 -4
  23. package/.claude/skills/components/tabbed-content.md +300 -0
  24. package/.claude/skills/composable-anchor-scroll.md +16 -15
  25. package/.claude/skills/index.md +4 -5
  26. package/.claude/skills/storybook-add-story.md +53 -0
  27. package/.claude/skills/theming-status-tokens.md +60 -0
  28. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  29. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  30. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  31. package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
  32. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  33. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  34. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  35. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  36. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  37. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  38. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  39. package/app/assets/styles/setup/03.theming/index.css +1 -0
  40. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  41. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  42. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  43. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  44. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  45. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  46. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  47. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  48. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  49. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  50. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
  51. package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
  52. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  53. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  54. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  55. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  56. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  57. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  58. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  59. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  60. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  61. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  62. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  63. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  64. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  65. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  66. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  67. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  68. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  69. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  70. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  71. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  72. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  73. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  74. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  75. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  76. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  77. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  78. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  79. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  80. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  81. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  82. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  83. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  84. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  85. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  86. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  87. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  88. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  89. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  90. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  91. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  92. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  93. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  94. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  95. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  96. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  97. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  98. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  99. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
  100. package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
  101. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
  102. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  103. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  104. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  105. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  106. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  107. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  108. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  109. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
  110. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  111. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  112. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  113. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  114. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
  115. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  116. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  117. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  118. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  119. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  120. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  121. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  122. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  123. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  124. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  125. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
  126. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  127. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  128. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  129. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  130. package/app/composables/useAnchorScroll.ts +1 -1
  131. package/app/composables/useTabs.ts +148 -206
  132. package/app/types/components/display-chip.d.ts +8 -4
  133. package/app/types/components/display-toast.d.ts +2 -0
  134. package/app/types/components/index.ts +0 -1
  135. package/app/types/components/select-menu.d.ts +2 -0
  136. package/app/utils/pausableTimeout.ts +41 -0
  137. package/nuxt.config.ts +1 -1
  138. package/package.json +1 -1
  139. package/.claude/skills/components/site-navigation.md +0 -145
  140. package/.claude/skills/components/tab-navigation.md +0 -272
  141. package/.claude/skills/components/tabs-core.md +0 -187
  142. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  143. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  144. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  145. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  146. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  147. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  148. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  149. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  150. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  151. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  152. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
  153. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
  154. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
  155. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
  156. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
  157. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  158. package/app/composables/useNavCollapse.ts +0 -86
  159. package/app/types/components/nav-item.d.ts +0 -11
@@ -4,13 +4,21 @@
4
4
  v-if="privateDisplayToast"
5
5
  ref="toastElementRef"
6
6
  class="display-toast"
7
- :class="[elementClasses, cssStateClass, positionClasses]"
7
+ :class="elementClasses"
8
+ :data-state="toastState"
9
+ :data-position="position"
10
+ :data-alignment="fullWidth ? 'full-width' : alignment"
11
+ :data-paused="isPaused ? '' : undefined"
8
12
  :data-theme="theme"
9
13
  :role="toastRole"
10
14
  :aria-live="ariaLive"
11
15
  :tabindex="slots.default ? undefined : '0'"
12
16
  :aria-describedby="slots.default ? undefined : 'toast-message-' + toastId"
13
17
  @keydown.escape="setDismissToast"
18
+ @pointerenter="isHovered = true"
19
+ @pointerleave="isHovered = false"
20
+ @focusin="handleFocusIn"
21
+ @focusout="handleFocusOut"
14
22
  >
15
23
  <slot v-if="slots.default"></slot>
16
24
 
@@ -32,6 +40,7 @@
32
40
  <template v-if="slots.description || toastDescription" #content>
33
41
  <slot name="description">{{ toastDescription }}</slot>
34
42
  </template>
43
+ <template v-if="dismissLabel" #dismissLabel>{{ dismissLabel }}</template>
35
44
  </component>
36
45
  <div v-if="autoDismiss" class="display-toast-progress"></div>
37
46
  </div>
@@ -75,18 +84,7 @@ const toastDisplayText = computed(() => props.config?.content?.text ?? "");
75
84
  const toastTitle = computed(() => props.config?.content?.title ?? "");
76
85
  const toastDescription = computed(() => props.config?.content?.description ?? "");
77
86
  const customIcon = computed(() => props.config?.content?.customIcon);
78
-
79
- // Computed classes for positioning
80
- const positionClasses = computed(() => {
81
- const classes = [];
82
- classes.push(position.value);
83
- if (fullWidth.value) {
84
- classes.push("full-width");
85
- } else {
86
- classes.push(alignment.value);
87
- }
88
- return classes;
89
- });
87
+ const dismissLabel = computed(() => props.config?.content?.dismissLabel);
90
88
 
91
89
  /*
92
90
  * Accessibility setup
@@ -109,9 +107,30 @@ const ariaLive = computed(() => {
109
107
  const externalTriggerModel = defineModel<boolean>({ default: false });
110
108
  const privateDisplayToast = ref(false);
111
109
  const transitionalState = ref(false);
112
- const cssStateClass = computed(() => {
113
- return transitionalState.value ? "show" : "hide";
114
- });
110
+ const toastState = computed(() => (transitionalState.value ? "show" : "hide"));
111
+
112
+ /*
113
+ * Auto-dismiss pauses while hovered or keyboard-focused (WCAG 2.2.2)
114
+ */
115
+ const isHovered = ref(false);
116
+ const isFocusPaused = ref(false);
117
+ const isPaused = computed(() => autoDismiss.value && (isHovered.value || isFocusPaused.value));
118
+ let dismissTimer: ReturnType<typeof createPausableTimeout> | null = null;
119
+
120
+ const handleFocusIn = (event: FocusEvent) => {
121
+ if (toastElementRef.value) isFocusPaused.value = focusPausesAutoDismiss(toastElementRef.value, event.target);
122
+ };
123
+
124
+ const handleFocusOut = (event: FocusEvent) => {
125
+ if (!toastElementRef.value?.contains(event.relatedTarget as Node | null)) isFocusPaused.value = false;
126
+ };
127
+
128
+ watch(isPaused, (paused) => (paused ? dismissTimer?.pause() : dismissTimer?.resume()));
129
+
130
+ const clearDismissTimer = () => {
131
+ dismissTimer?.clear();
132
+ dismissTimer = null;
133
+ };
115
134
 
116
135
  /*
117
136
  * Computed properties for durations (in ms for CSS)
@@ -123,6 +142,7 @@ const displayDurationMs = computed(() => duration.value + "ms");
123
142
  * Lifecycle hooks
124
143
  */
125
144
  const setDismissToast = async () => {
145
+ clearDismissTimer();
126
146
  transitionalState.value = false;
127
147
  await useSleep(revealDuration.value);
128
148
 
@@ -144,6 +164,8 @@ const setDismissToast = async () => {
144
164
 
145
165
  externalTriggerModel.value = false;
146
166
  privateDisplayToast.value = false;
167
+ isHovered.value = false;
168
+ isFocusPaused.value = false;
147
169
  };
148
170
 
149
171
  watch(
@@ -170,8 +192,9 @@ watch(
170
192
  }
171
193
 
172
194
  if (autoDismiss.value) {
173
- await useSleep(duration.value);
174
- setDismissToast();
195
+ clearDismissTimer();
196
+ dismissTimer = createPausableTimeout(setDismissToast, duration.value);
197
+ if (isPaused.value) dismissTimer.pause();
175
198
  }
176
199
  } else if (!newValue && previousValue) {
177
200
  // If external model is set to false, dismiss the toast
@@ -183,18 +206,20 @@ watch(
183
206
  onBeforeRouteLeave(() => {
184
207
  setDismissToast();
185
208
  });
209
+
210
+ onBeforeUnmount(clearDismissTimer);
186
211
  </script>
187
212
 
188
213
  <style lang="css">
189
214
  @layer components {
190
- @keyframes show {
215
+ @keyframes display-toast-show {
191
216
  to {
192
217
  opacity: 1;
193
218
  transform: translateY(0);
194
219
  }
195
220
  }
196
221
 
197
- @keyframes hideTop {
222
+ @keyframes display-toast-hide-top {
198
223
  0% {
199
224
  opacity: 1;
200
225
  transform: translateY(0);
@@ -205,7 +230,7 @@ onBeforeRouteLeave(() => {
205
230
  }
206
231
  }
207
232
 
208
- @keyframes hideBottom {
233
+ @keyframes display-toast-hide-bottom {
209
234
  0% {
210
235
  opacity: 1;
211
236
  transform: translateY(0);
@@ -216,7 +241,22 @@ onBeforeRouteLeave(() => {
216
241
  }
217
242
  }
218
243
 
219
- @keyframes progress {
244
+ @keyframes display-toast-fade-in {
245
+ to {
246
+ opacity: 1;
247
+ }
248
+ }
249
+
250
+ @keyframes display-toast-fade-out {
251
+ from {
252
+ opacity: 1;
253
+ }
254
+ to {
255
+ opacity: 0;
256
+ }
257
+ }
258
+
259
+ @keyframes display-toast-progress {
220
260
  to {
221
261
  transform: scaleX(1);
222
262
  }
@@ -228,19 +268,18 @@ onBeforeRouteLeave(() => {
228
268
  --_toast-gutter: 24px;
229
269
  }
230
270
 
231
- /* See the matching comment in DisplayToastProvider.vue — same fix, same rationale. */
232
-
233
271
  display: block;
234
272
  overflow: hidden;
273
+ overflow-wrap: anywhere;
235
274
  position: fixed;
236
275
  margin: 0;
237
276
  opacity: 0;
277
+ max-inline-size: var(--display-toast-max-width, min(48rem, 100% - 2 * var(--_toast-gutter)));
238
278
 
239
279
  z-index: var(--display-toast-z-index, 999999);
240
280
 
241
- /* Focus styles for accessibility */
242
281
  &:focus {
243
- outline: 2px solid var(--theme-ring);
282
+ outline: 2px solid var(--display-toast-focus-ring-colour, var(--theme-ring));
244
283
  outline-offset: 2px;
245
284
  }
246
285
 
@@ -248,71 +287,91 @@ onBeforeRouteLeave(() => {
248
287
  outline: none;
249
288
  }
250
289
 
251
- &.show {
290
+ &[data-state="show"] {
252
291
  @supports (animation-timing-function: linear(0, 1)) {
253
- animation: show v-bind(revealDurationMs) var(--spring-easing) forwards;
292
+ animation: display-toast-show v-bind(revealDurationMs) var(--spring-easing) forwards;
254
293
  }
255
294
 
256
295
  @supports not (animation-timing-function: linear(0, 1)) {
257
- animation: show calc(v-bind(revealDurationMs) / 2) linear forwards;
296
+ animation: display-toast-show calc(v-bind(revealDurationMs) / 2) linear forwards;
258
297
  }
259
298
  }
260
299
 
261
- &.hide {
300
+ &[data-state="hide"] {
262
301
  @supports (animation-timing-function: linear(0, 1)) {
263
- animation: hideTop v-bind(revealDurationMs) var(--spring-easing) forwards;
302
+ animation: display-toast-hide-top v-bind(revealDurationMs) var(--spring-easing) forwards;
264
303
  }
265
304
 
266
305
  @supports not (animation-timing-function: linear(0, 1)) {
267
- animation: hideTop calc(v-bind(revealDurationMs) / 2) linear forwards;
306
+ animation: display-toast-hide-top calc(v-bind(revealDurationMs) / 2) linear forwards;
268
307
  }
269
308
 
270
- &.bottom {
309
+ &[data-position="bottom"] {
271
310
  @supports (animation-timing-function: linear(0, 1)) {
272
- animation: hideBottom v-bind(revealDurationMs) var(--spring-easing) forwards;
311
+ animation: display-toast-hide-bottom v-bind(revealDurationMs) var(--spring-easing) forwards;
273
312
  }
274
313
 
275
314
  @supports not (animation-timing-function: linear(0, 1)) {
276
- animation: hideBottom calc(v-bind(revealDurationMs) / 2) linear forwards;
315
+ animation: display-toast-hide-bottom calc(v-bind(revealDurationMs) / 2) linear forwards;
277
316
  }
278
317
  }
279
318
  }
280
319
 
281
- /*
282
- * Default is centre for smaller screens
283
- */
320
+ /* Centred on small screens */
284
321
  inset-inline: var(--_toast-gutter);
285
322
  margin-inline: auto;
286
323
 
287
324
  @media (width >= 600px) {
288
- &.left {
325
+ &[data-alignment="left"] {
289
326
  inset-inline-start: var(--_toast-gutter);
290
327
  inset-inline-end: unset;
291
328
  }
292
329
 
293
- &.right {
330
+ &[data-alignment="right"] {
294
331
  inset-inline-end: var(--_toast-gutter);
295
332
  inset-inline-start: unset;
296
333
  }
297
334
 
298
- &.center {
299
- &:not(.full-width) {
300
- inset-inline: 0;
301
- margin-inline: auto;
302
- width: max-content;
303
- }
335
+ &[data-alignment="center"] {
336
+ inset-inline: 0;
337
+ margin-inline: auto;
338
+ width: fit-content;
304
339
  }
305
340
  }
306
341
 
307
- &.top {
342
+ &[data-alignment="full-width"] {
343
+ max-inline-size: none;
344
+ }
345
+
346
+ &[data-position="top"] {
308
347
  inset-block-start: var(--_toast-gutter);
309
348
  transform: translateY(-30px);
310
349
  }
311
- &.bottom {
350
+
351
+ &[data-position="bottom"] {
312
352
  inset-block-end: var(--_toast-gutter);
313
353
  transform: translateY(30px);
314
354
  }
315
355
 
356
+ /* After the position and state rules so it wins at equal specificity. */
357
+ @media (prefers-reduced-motion: reduce) {
358
+ &[data-position] {
359
+ transform: none;
360
+ }
361
+
362
+ &[data-state="show"][data-position] {
363
+ animation: display-toast-fade-in v-bind(revealDurationMs) linear forwards;
364
+ }
365
+
366
+ &[data-state="hide"][data-position] {
367
+ animation: display-toast-fade-out v-bind(revealDurationMs) linear forwards;
368
+ }
369
+ }
370
+
371
+ &[data-paused] .display-toast-progress {
372
+ animation-play-state: paused;
373
+ }
374
+
316
375
  .display-toast-progress {
317
376
  position: absolute;
318
377
  inset-block-end: 4px;
@@ -320,9 +379,9 @@ onBeforeRouteLeave(() => {
320
379
  height: 3px;
321
380
  transform: scaleX(0);
322
381
  transform-origin: right;
323
- background: var(--theme-accent);
382
+ background: var(--display-toast-progress-colour, var(--theme-accent));
324
383
  border-radius: inherit;
325
- animation: progress v-bind(displayDurationMs) linear forwards;
384
+ animation: display-toast-progress v-bind(displayDurationMs) linear forwards;
326
385
  }
327
386
  }
328
387
  }
@@ -3,7 +3,8 @@
3
3
  <TransitionGroup
4
4
  tag="div"
5
5
  class="display-toast-provider"
6
- :class="[position, fullWidth ? 'full-width' : alignment]"
6
+ :data-position="position"
7
+ :data-alignment="fullWidth ? 'full-width' : alignment"
7
8
  @before-leave="onBeforeLeave"
8
9
  @after-leave="onAfterLeave"
9
10
  @after-enter="onAfterEnter"
@@ -21,7 +22,12 @@
21
22
  :aria-live="ariaLiveFor(entry)"
22
23
  tabindex="0"
23
24
  :aria-describedby="'toast-message-' + entry.id"
25
+ :data-paused="isPaused(entry) ? '' : undefined"
24
26
  @keydown.escape="handleDismiss(entry.id)"
27
+ @pointerenter="setPauseReason(entry.id, 'hover', true)"
28
+ @pointerleave="setPauseReason(entry.id, 'hover', false)"
29
+ @focusin="handleFocusIn(entry.id, $event)"
30
+ @focusout="handleFocusOut(entry.id, $event)"
25
31
  >
26
32
  <component
27
33
  :is="maskedFor(entry) ? AlertMaskedContent : AlertContent"
@@ -37,6 +43,9 @@
37
43
  <template v-if="entry.config.content?.description" #content>
38
44
  {{ entry.config.content?.description }}
39
45
  </template>
46
+ <template v-if="entry.config.content?.dismissLabel" #dismissLabel>
47
+ {{ entry.config.content?.dismissLabel }}
48
+ </template>
40
49
  </component>
41
50
  <div v-if="autoDismissFor(entry)" class="display-toast-provider-progress"></div>
42
51
  </div>
@@ -83,21 +92,50 @@ const roleFor = (entry: ToastQueueEntry) => (["error", "warning"].includes(theme
83
92
  const ariaLiveFor = (entry: ToastQueueEntry) =>
84
93
  ["error", "warning"].includes(themeFor(entry)) ? "assertive" : "polite";
85
94
 
86
- const timers = new Map<string, ReturnType<typeof setTimeout>>();
95
+ const timers = new Map<string, ReturnType<typeof createPausableTimeout>>();
87
96
 
88
- const handleDismiss = (id: string) => {
97
+ // Auto-dismiss pauses while hovered or keyboard-focused (WCAG 2.2.2).
98
+ const pauseReasons = ref<Record<string, { hover: boolean; focus: boolean }>>({});
99
+
100
+ const isPaused = (entry: ToastQueueEntry) => {
101
+ const reasons = pauseReasons.value[entry.id];
102
+ return autoDismissFor(entry) && !!reasons && (reasons.hover || reasons.focus);
103
+ };
104
+
105
+ const setPauseReason = (id: string, reason: "hover" | "focus", active: boolean) => {
106
+ const reasons = { hover: false, focus: false, ...pauseReasons.value[id], [reason]: active };
107
+ pauseReasons.value = { ...pauseReasons.value, [id]: reasons };
89
108
  const timer = timers.get(id);
90
- if (timer !== undefined) {
91
- clearTimeout(timer);
92
- timers.delete(id);
109
+ if (reasons.hover || reasons.focus) timer?.pause();
110
+ else timer?.resume();
111
+ };
112
+
113
+ const handleFocusIn = (id: string, event: FocusEvent) => {
114
+ setPauseReason(id, "focus", focusPausesAutoDismiss(event.currentTarget as HTMLElement, event.target));
115
+ };
116
+
117
+ const handleFocusOut = (id: string, event: FocusEvent) => {
118
+ if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node | null)) {
119
+ setPauseReason(id, "focus", false);
93
120
  }
121
+ };
122
+
123
+ const forgetEntry = (id: string) => {
124
+ timers.get(id)?.clear();
125
+ timers.delete(id);
126
+ const { [id]: _removed, ...rest } = pauseReasons.value;
127
+ pauseReasons.value = rest;
128
+ };
129
+
130
+ const handleDismiss = (id: string) => {
131
+ forgetEntry(id);
94
132
  dismiss(id);
95
133
  };
96
134
 
97
135
  const startTimer = (entry: ToastQueueEntry) => {
98
136
  if (!autoDismissFor(entry)) return;
99
- const timer = setTimeout(() => {
100
- timers.delete(entry.id);
137
+ const timer = createPausableTimeout(() => {
138
+ forgetEntry(entry.id);
101
139
  dismiss(entry.id);
102
140
  }, displayDurationFor(entry));
103
141
  timers.set(entry.id, timer);
@@ -110,11 +148,8 @@ watch(
110
148
 
111
149
  // Clear timers for entries that no longer exist (e.g. clear()).
112
150
  const entryIds = new Set(entries.map((e) => e.id));
113
- for (const [id, timer] of timers) {
114
- if (!entryIds.has(id)) {
115
- clearTimeout(timer);
116
- timers.delete(id);
117
- }
151
+ for (const id of timers.keys()) {
152
+ if (!entryIds.has(id)) forgetEntry(id);
118
153
  }
119
154
 
120
155
  const visibleCount = entries.filter((e) => e.status === "visible").length;
@@ -157,14 +192,14 @@ const onAfterEnter = (el: Element) => {
157
192
  };
158
193
 
159
194
  onUnmounted(() => {
160
- timers.forEach(clearTimeout);
195
+ timers.forEach((timer) => timer.clear());
161
196
  timers.clear();
162
197
  });
163
198
  </script>
164
199
 
165
200
  <style lang="css">
166
201
  @layer components {
167
- @keyframes hideTop {
202
+ @keyframes display-toast-provider-hide-top {
168
203
  0% {
169
204
  opacity: 1;
170
205
  transform: translateY(0);
@@ -175,7 +210,7 @@ onUnmounted(() => {
175
210
  }
176
211
  }
177
212
 
178
- @keyframes hideBottom {
213
+ @keyframes display-toast-provider-hide-bottom {
179
214
  0% {
180
215
  opacity: 1;
181
216
  transform: translateY(0);
@@ -186,13 +221,13 @@ onUnmounted(() => {
186
221
  }
187
222
  }
188
223
 
189
- @keyframes progress {
224
+ @keyframes display-toast-provider-progress {
190
225
  to {
191
226
  transform: scaleX(1);
192
227
  }
193
228
  }
194
229
 
195
- @keyframes showTop {
230
+ @keyframes display-toast-provider-show-top {
196
231
  from {
197
232
  opacity: 0;
198
233
  transform: translateY(-30px);
@@ -203,7 +238,16 @@ onUnmounted(() => {
203
238
  }
204
239
  }
205
240
 
206
- @keyframes showBottom {
241
+ @keyframes display-toast-provider-fade {
242
+ from {
243
+ opacity: 0;
244
+ }
245
+ to {
246
+ opacity: 1;
247
+ }
248
+ }
249
+
250
+ @keyframes display-toast-provider-show-bottom {
207
251
  from {
208
252
  opacity: 0;
209
253
  transform: translateY(30px);
@@ -220,12 +264,6 @@ onUnmounted(() => {
220
264
  --_gutter: 24px;
221
265
  }
222
266
 
223
- /* No public token before 2026-08-22 and hardcoded far below DisplayDialog's own
224
- --display-dialog-z-index default (999999) — a toast is meant to float above ordinary page
225
- chrome, but 100 loses to any consumer's sticky header. Matches DisplayDialog's default so
226
- toasts also clear a modal's own overlay; the two ties on DOM/paint order if both happen to
227
- be visible at once, which is an acceptable edge case for a transient notification. */
228
-
229
267
  position: fixed;
230
268
  display: flex;
231
269
  flex-direction: column;
@@ -235,36 +273,36 @@ onUnmounted(() => {
235
273
  inset-inline: var(--_gutter);
236
274
  margin-inline: auto;
237
275
  width: max-content;
238
- max-width: calc(100% - 2 * var(--_gutter));
276
+ max-width: var(--display-toast-provider-max-width, min(48rem, 100% - 2 * var(--_gutter)));
239
277
 
240
- &.top {
278
+ &[data-position="top"] {
241
279
  inset-block-start: var(--_gutter);
242
280
  }
243
281
 
244
- &.bottom {
282
+ &[data-position="bottom"] {
245
283
  inset-block-end: var(--_gutter);
246
284
  flex-direction: column-reverse;
247
285
  }
248
286
 
249
287
  @media (width >= 600px) {
250
- &.left {
288
+ &[data-alignment="left"] {
251
289
  inset-inline-start: var(--_gutter);
252
290
  inset-inline-end: unset;
253
291
  margin-inline: 0;
254
292
  }
255
293
 
256
- &.right {
294
+ &[data-alignment="right"] {
257
295
  inset-inline-end: var(--_gutter);
258
296
  inset-inline-start: unset;
259
297
  margin-inline: 0;
260
298
  }
261
299
 
262
- &.center:not(.full-width) {
300
+ &[data-alignment="center"] {
263
301
  inset-inline: 0;
264
302
  margin-inline: auto;
265
303
  }
266
304
 
267
- &.full-width {
305
+ &[data-alignment="full-width"] {
268
306
  inset-inline: var(--_gutter);
269
307
  width: unset;
270
308
  max-width: unset;
@@ -279,9 +317,10 @@ onUnmounted(() => {
279
317
  display: block;
280
318
  position: relative;
281
319
  overflow: hidden;
320
+ overflow-wrap: anywhere;
282
321
 
283
322
  &:focus {
284
- outline: 2px solid var(--theme-ring);
323
+ outline: 2px solid var(--display-toast-provider-focus-ring-colour, var(--theme-ring));
285
324
  outline-offset: 2px;
286
325
  }
287
326
 
@@ -290,7 +329,7 @@ onUnmounted(() => {
290
329
  }
291
330
 
292
331
  /* TransitionGroup enter/leave */
293
- .display-toast-provider.top & {
332
+ .display-toast-provider[data-position="top"] & {
294
333
  &.v-enter-from {
295
334
  opacity: 0;
296
335
  transform: translateY(-30px);
@@ -298,11 +337,11 @@ onUnmounted(() => {
298
337
 
299
338
  &.v-enter-active {
300
339
  @supports (animation-timing-function: linear(0, 1)) {
301
- animation: showTop var(--_reveal) var(--spring-easing) forwards;
340
+ animation: display-toast-provider-show-top var(--_reveal) var(--spring-easing) forwards;
302
341
  }
303
342
 
304
343
  @supports not (animation-timing-function: linear(0, 1)) {
305
- animation: showTop calc(var(--_reveal) / 2) linear forwards;
344
+ animation: display-toast-provider-show-top calc(var(--_reveal) / 2) linear forwards;
306
345
  }
307
346
  }
308
347
 
@@ -310,16 +349,16 @@ onUnmounted(() => {
310
349
  position: absolute;
311
350
 
312
351
  @supports (animation-timing-function: linear(0, 1)) {
313
- animation: hideTop var(--_reveal) var(--spring-easing) forwards;
352
+ animation: display-toast-provider-hide-top var(--_reveal) var(--spring-easing) forwards;
314
353
  }
315
354
 
316
355
  @supports not (animation-timing-function: linear(0, 1)) {
317
- animation: hideTop calc(var(--_reveal) / 2) linear forwards;
356
+ animation: display-toast-provider-hide-top calc(var(--_reveal) / 2) linear forwards;
318
357
  }
319
358
  }
320
359
  }
321
360
 
322
- .display-toast-provider.bottom & {
361
+ .display-toast-provider[data-position="bottom"] & {
323
362
  &.v-enter-from {
324
363
  opacity: 0;
325
364
  transform: translateY(30px);
@@ -327,11 +366,11 @@ onUnmounted(() => {
327
366
 
328
367
  &.v-enter-active {
329
368
  @supports (animation-timing-function: linear(0, 1)) {
330
- animation: showBottom var(--_reveal) var(--spring-easing) forwards;
369
+ animation: display-toast-provider-show-bottom var(--_reveal) var(--spring-easing) forwards;
331
370
  }
332
371
 
333
372
  @supports not (animation-timing-function: linear(0, 1)) {
334
- animation: showBottom calc(var(--_reveal) / 2) linear forwards;
373
+ animation: display-toast-provider-show-bottom calc(var(--_reveal) / 2) linear forwards;
335
374
  }
336
375
  }
337
376
 
@@ -339,11 +378,11 @@ onUnmounted(() => {
339
378
  position: absolute;
340
379
 
341
380
  @supports (animation-timing-function: linear(0, 1)) {
342
- animation: hideBottom var(--_reveal) var(--spring-easing) forwards;
381
+ animation: display-toast-provider-hide-bottom var(--_reveal) var(--spring-easing) forwards;
343
382
  }
344
383
 
345
384
  @supports not (animation-timing-function: linear(0, 1)) {
346
- animation: hideBottom calc(var(--_reveal) / 2) linear forwards;
385
+ animation: display-toast-provider-hide-bottom calc(var(--_reveal) / 2) linear forwards;
347
386
  }
348
387
  }
349
388
  }
@@ -352,6 +391,31 @@ onUnmounted(() => {
352
391
  transition: transform 0.3s ease;
353
392
  }
354
393
 
394
+ /* Same selector shape as the position rules above, later in source, so it wins. */
395
+ @media (prefers-reduced-motion: reduce) {
396
+ .display-toast-provider[data-position] & {
397
+ &.v-enter-from {
398
+ transform: none;
399
+ }
400
+
401
+ &.v-enter-active {
402
+ animation: display-toast-provider-fade var(--_reveal) linear forwards;
403
+ }
404
+
405
+ &.v-leave-active {
406
+ animation: display-toast-provider-fade var(--_reveal) linear reverse forwards;
407
+ }
408
+
409
+ &.v-move {
410
+ transition: none;
411
+ }
412
+ }
413
+ }
414
+
415
+ &[data-paused] .display-toast-provider-progress {
416
+ animation-play-state: paused;
417
+ }
418
+
355
419
  .display-toast-provider-progress {
356
420
  position: absolute;
357
421
  inset-block-end: 4px;
@@ -359,9 +423,9 @@ onUnmounted(() => {
359
423
  height: 3px;
360
424
  transform: scaleX(0);
361
425
  transform-origin: right;
362
- background: var(--theme-accent);
426
+ background: var(--display-toast-provider-progress-colour, var(--theme-accent));
363
427
  border-radius: inherit;
364
- animation: progress var(--_duration) linear forwards;
428
+ animation: display-toast-provider-progress var(--_duration) linear forwards;
365
429
  }
366
430
  }
367
431
  }
@@ -193,3 +193,26 @@ FullWidth.args = {
193
193
  position: "top",
194
194
  autoDismiss: false,
195
195
  };
196
+
197
+ export const StressTest = Template.bind({});
198
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
199
+ StressTest.args = {
200
+ theme: "error",
201
+ alignment: "center",
202
+ autoDismiss: true,
203
+ duration: 10000,
204
+ title: "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft",
205
+ description:
206
+ "Ihre Zahlung konnte leider nicht verarbeitet werden. Bitte überprüfen Sie Ihre Kartendaten und versuchen Sie es erneut, oder wenden Sie sich an Ihre Bank. https://example.com/a-very-long-path-segment-without-any-spaces-at-all-in-it",
207
+ };
208
+ StressTest.parameters = {
209
+ docs: {
210
+ description: {
211
+ story:
212
+ "Long unbroken title, long German description and a long URL, centred (the alignment most likely to overflow). " +
213
+ "Toasts are fixed to the viewport, so use Storybook's viewport toolbar to check narrow screens. Try every alignment " +
214
+ "and fullWidth: the toast should stay inside the viewport, cap at 48rem on wide screens, and wrap the unbroken text. " +
215
+ "Hover it or tab to it to check the progress bar and the 10s timer pause.",
216
+ },
217
+ },
218
+ };