srcdev-nuxt-components 9.6.10 → 9.6.12

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 (125) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +14 -3
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +2 -1
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/form-field.md +15 -0
  10. package/.claude/skills/components/form-fieldset.md +18 -2
  11. package/.claude/skills/components/input-button.md +26 -7
  12. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  13. package/.claude/skills/components/input-description.md +5 -0
  14. package/.claude/skills/components/input-error.md +9 -2
  15. package/.claude/skills/components/input-label.md +8 -2
  16. package/.claude/skills/components/input-number.md +2 -0
  17. package/.claude/skills/components/input-otp.md +2 -0
  18. package/.claude/skills/components/input-range.md +2 -0
  19. package/.claude/skills/components/input-select.md +2 -0
  20. package/.claude/skills/components/input-text.md +2 -0
  21. package/.claude/skills/components/input-textarea.md +2 -0
  22. package/.claude/skills/components/marquee-scroller.md +51 -14
  23. package/.claude/skills/components/pending-effect.md +5 -4
  24. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  25. package/.claude/skills/components/services-card.md +1 -1
  26. package/.claude/skills/components/tabbed-content.md +1 -1
  27. package/.claude/skills/components/toggle-switch.md +2 -0
  28. package/.claude/skills/index.md +5 -5
  29. package/.claude/skills/storybook-add-story.md +3 -1
  30. package/.claude/skills/theming-component-token-pattern.md +24 -0
  31. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  32. package/.claude/skills/theming-partial-override.md +1 -1
  33. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  34. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  35. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  36. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  37. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  38. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  39. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  40. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  41. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  42. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  43. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  44. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  45. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  46. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  47. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  48. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  49. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  50. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  51. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  52. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  53. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  54. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  55. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  56. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  57. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  58. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  59. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  60. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  61. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  62. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  63. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  64. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  65. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  66. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  67. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  68. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  69. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  70. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  71. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  72. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  73. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  74. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  75. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  76. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  77. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  78. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  79. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  80. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  81. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  82. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  83. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  84. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  85. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  86. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  87. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  88. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  89. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  90. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  91. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  92. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  93. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  94. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  95. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  96. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  97. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  98. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  99. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  100. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  101. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  102. package/app/components/05.forms/input-text/InputText.vue +4 -4
  103. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  104. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  105. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  106. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  107. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  108. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  109. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  110. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  111. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  112. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  113. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  114. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  115. package/app/types/components/index.ts +1 -1
  116. package/package.json +1 -1
  117. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  118. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  119. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  120. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  121. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  122. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  123. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  124. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  125. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -3,42 +3,48 @@
3
3
  v-if="displayComponent"
4
4
  ref="rootEl"
5
5
  class="marquee-scroller"
6
- :class="{ reverse: reverse, paused: isPaused, 'reduced-motion': prefersReducedMotion }"
6
+ :class="elementClasses"
7
+ :data-reverse="reverse ? '' : undefined"
8
+ :data-paused="isPaused ? '' : undefined"
9
+ :data-reduced-motion="prefersReducedMotion ? '' : undefined"
7
10
  role="region"
8
11
  :aria-label="ariaLabel"
9
12
  :aria-live="isPaused ? 'polite' : 'off'"
10
13
  tabindex="0"
11
14
  @keydown="handleKeydown"
12
- @focus="handleFocus"
13
- @blur="handleBlur"
15
+ @focusin="handleFocusIn"
16
+ @focusout="handleFocusOut"
14
17
  >
15
- <div class="sr-only">
18
+ <div class="marquee-scroller-description">
16
19
  {{ ariaDescription }}
17
20
  </div>
18
21
 
19
- <button
22
+ <InputButton
20
23
  v-if="showControls"
21
- class="control-btn"
22
- :aria-label="isPaused ? playLabel : pauseLabel"
23
24
  type="button"
25
+ variant="inline"
26
+ :button-text="isPaused ? playLabel : pauseLabel"
27
+ style-class-passthrough="marquee-scroller-control"
24
28
  @click="togglePause"
25
29
  >
26
- <slot name="toggle-icon" :is-paused="isPaused">
27
- <Icon :name="isPaused ? playIcon : pauseIcon" aria-hidden="true" />
28
- </slot>
29
- </button>
30
-
31
- <div class="marquee-track" :aria-hidden="!isPaused">
32
- <div ref="groupEl" class="marquee-group">
30
+ <template #iconOnly>
31
+ <slot name="toggle-icon" :is-paused="isPaused">
32
+ <Icon :name="isPaused ? playIcon : pauseIcon" aria-hidden="true" />
33
+ </slot>
34
+ </template>
35
+ </InputButton>
36
+
37
+ <div class="marquee-scroller-track" :aria-hidden="isPaused ? undefined : 'true'">
38
+ <div ref="groupEl" class="marquee-scroller-group">
33
39
  <template v-for="copy in repeatCount" :key="copy">
34
- <div v-for="item in marqueeData" :key="`${copy}-${item.id}`" class="item">
40
+ <div v-for="item in marqueeData" :key="`${copy}-${item.id}`" class="marquee-scroller-item">
35
41
  <slot :name="item.id"></slot>
36
42
  </div>
37
43
  </template>
38
44
  </div>
39
- <div class="marquee-group" aria-hidden="true">
45
+ <div class="marquee-scroller-group" aria-hidden="true" inert>
40
46
  <template v-for="copy in repeatCount" :key="copy">
41
- <div v-for="item in marqueeData" :key="`duplicate-${copy}-${item.id}`" class="item">
47
+ <div v-for="item in marqueeData" :key="`duplicate-${copy}-${item.id}`" class="marquee-scroller-item">
42
48
  <slot :name="item.id"></slot>
43
49
  </div>
44
50
  </template>
@@ -65,10 +71,11 @@ interface Props {
65
71
  playIcon?: string;
66
72
  /** Iconify icon name shown on the control button while playing. Ignored if the toggle-icon slot is used. */
67
73
  pauseIcon?: string;
68
- /** Control button aria-label while paused — override for localisation. */
74
+ /** Control button label while paused — override for localisation. */
69
75
  playLabel?: string;
70
- /** Control button aria-label while playing — override for localisation. */
76
+ /** Control button label while playing — override for localisation. */
71
77
  pauseLabel?: string;
78
+ styleClassPassthrough?: string | string[];
72
79
  }
73
80
 
74
81
  const props = withDefaults(defineProps<Props>(), {
@@ -84,74 +91,83 @@ const props = withDefaults(defineProps<Props>(), {
84
91
  pauseIcon: "mdi:pause",
85
92
  playLabel: "Play animation",
86
93
  pauseLabel: "Pause animation",
94
+ styleClassPassthrough: () => [],
87
95
  });
88
96
 
97
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
98
+
99
+ const MAX_REPEAT_COUNT = 50;
100
+
89
101
  const displayComponent = ref(false);
90
102
  const isPaused = ref(false);
91
- const isFocused = ref(false);
92
103
  const prefersReducedMotion = ref(false);
93
104
  const rootEl = ref<HTMLElement | null>(null);
94
105
  const groupEl = ref<HTMLElement | null>(null);
95
106
  const repeatCount = ref(1);
107
+ let pausedByFocus = false;
96
108
  let resizeObserver: ResizeObserver | null = null;
109
+ let reducedMotionQuery: MediaQueryList | null = null;
97
110
 
98
111
  const height = computed(() => props.itemConfig.height || "50px");
99
112
  const width = computed(() => props.itemConfig.width || "50px");
100
113
  const gap = computed(() => props.itemConfig.gap || "16px");
101
114
  const itemCount = computed(() => props.marqueeData.length * repeatCount.value);
102
115
 
103
- // A single copy of marqueeData may not be wide enough to fill a wide container — if it
104
- // isn't, the loop briefly shows empty space before snapping back into view. Repeat the
105
- // data enough times per track group to always exceed the container's width.
116
+ // One copy of marqueeData may not fill a wide container; repeat it until a group covers the width.
106
117
  const updateRepeatCount = () => {
107
118
  if (!rootEl.value || !groupEl.value || props.marqueeData.length === 0) return;
108
119
  const baseGroupWidth = groupEl.value.scrollWidth / repeatCount.value;
109
120
  if (baseGroupWidth <= 0) return;
110
- const needed = Math.max(1, Math.ceil(rootEl.value.offsetWidth / baseGroupWidth));
121
+ const needed = Math.min(MAX_REPEAT_COUNT, Math.max(1, Math.ceil(rootEl.value.offsetWidth / baseGroupWidth)));
111
122
  if (needed !== repeatCount.value) repeatCount.value = needed;
112
123
  };
113
124
 
114
- // Check for reduced motion preference
125
+ const handleReducedMotionChange = (event: MediaQueryListEvent) => {
126
+ prefersReducedMotion.value = event.matches;
127
+ if (event.matches) isPaused.value = true;
128
+ };
129
+
115
130
  const checkReducedMotion = () => {
116
- if (typeof window !== "undefined" && props.respectReducedMotion) {
117
- const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
118
- prefersReducedMotion.value = mediaQuery.matches;
119
-
120
- // Listen for changes
121
- mediaQuery.addEventListener("change", (e) => {
122
- prefersReducedMotion.value = e.matches;
123
- if (e.matches) {
124
- isPaused.value = true;
125
- }
126
- });
127
- }
131
+ if (typeof window === "undefined" || !props.respectReducedMotion) return;
132
+ reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
133
+ prefersReducedMotion.value = reducedMotionQuery.matches;
134
+ reducedMotionQuery.addEventListener("change", handleReducedMotionChange);
128
135
  };
129
136
 
130
137
  const togglePause = () => {
131
138
  isPaused.value = !isPaused.value;
139
+ pausedByFocus = false;
132
140
  };
133
141
 
134
142
  const handleKeydown = (event: KeyboardEvent) => {
143
+ if (event.target !== event.currentTarget) return;
135
144
  if (event.key === " " || event.key === "Spacebar") {
136
145
  event.preventDefault();
137
146
  togglePause();
138
147
  }
139
148
  };
140
149
 
141
- const handleFocus = () => {
142
- isFocused.value = true;
143
- if (props.respectReducedMotion) {
150
+ const handleFocusIn = (event: FocusEvent) => {
151
+ if ((event.target as Element | null)?.closest(".marquee-scroller-control")) return;
152
+ if (!isPaused.value) {
144
153
  isPaused.value = true;
154
+ pausedByFocus = true;
145
155
  }
146
156
  };
147
157
 
148
- const handleBlur = () => {
149
- isFocused.value = false;
150
- if (!prefersReducedMotion.value) {
151
- isPaused.value = false;
152
- }
158
+ const handleFocusOut = (event: FocusEvent) => {
159
+ if (rootEl.value?.contains(event.relatedTarget as Node | null)) return;
160
+ if (pausedByFocus && !prefersReducedMotion.value) isPaused.value = false;
161
+ pausedByFocus = false;
153
162
  };
154
163
 
164
+ watch(
165
+ () => props.styleClassPassthrough,
166
+ () => {
167
+ resetElementClasses(props.styleClassPassthrough);
168
+ }
169
+ );
170
+
155
171
  watch(
156
172
  () => [props.marqueeData, props.itemConfig],
157
173
  () => {
@@ -165,7 +181,6 @@ onMounted(async () => {
165
181
  displayComponent.value = true;
166
182
  checkReducedMotion();
167
183
 
168
- // Auto-pause if user prefers reduced motion
169
184
  if (prefersReducedMotion.value) {
170
185
  isPaused.value = true;
171
186
  }
@@ -181,6 +196,7 @@ onMounted(async () => {
181
196
 
182
197
  onUnmounted(() => {
183
198
  resizeObserver?.disconnect();
199
+ reducedMotionQuery?.removeEventListener("change", handleReducedMotionChange);
184
200
  });
185
201
  </script>
186
202
 
@@ -188,6 +204,8 @@ onUnmounted(() => {
188
204
  @layer components {
189
205
  .marquee-scroller {
190
206
  --_fade-width: var(--marquee-scroller-fade-width, 10%);
207
+ --_focus-outline: var(--marquee-scroller-focus-outline-width, 2px) solid
208
+ var(--marquee-scroller-focus-outline-colour, var(--theme-ring));
191
209
 
192
210
  width: 100%;
193
211
  height: v-bind(height);
@@ -200,32 +218,12 @@ onUnmounted(() => {
200
218
  );
201
219
  position: relative;
202
220
 
203
- /* Focus styles */
204
221
  &:focus-visible {
205
- outline: var(--marquee-scroller-focus-outline-width, 2px) solid var(--theme-ring);
222
+ outline: var(--_focus-outline);
206
223
  outline-offset: var(--marquee-scroller-focus-outline-offset, 2px);
207
224
  }
208
225
 
209
- /* Paused state */
210
- &.paused .marquee-track {
211
- animation-play-state: paused;
212
- }
213
-
214
- /* Reduced motion - disable animation completely */
215
- &.reduced-motion .marquee-track {
216
- animation: none !important;
217
- }
218
-
219
- &:hover .marquee-track {
220
- animation-play-state: paused;
221
- }
222
-
223
- &:hover .item {
224
- filter: var(--marquee-scroller-group-hover-filter, grayscale(1));
225
- }
226
-
227
- /* Screen reader only text */
228
- .sr-only {
226
+ .marquee-scroller-description {
229
227
  position: absolute;
230
228
  width: 1px;
231
229
  height: 1px;
@@ -237,12 +235,13 @@ onUnmounted(() => {
237
235
  border: 0;
238
236
  }
239
237
 
240
- /* Control button */
241
- .control-btn {
238
+ .marquee-scroller-control.input-button {
242
239
  position: absolute;
243
240
  inset-block-start: var(--marquee-scroller-control-offset, 8px);
244
241
  inset-inline-end: var(--marquee-scroller-control-offset, 8px);
245
242
  z-index: 10;
243
+ box-sizing: border-box;
244
+ min-height: auto;
246
245
  background: var(--marquee-scroller-control-background-colour, rgba(0, 0, 0, 0.7));
247
246
  color: var(--marquee-scroller-control-text-colour, white);
248
247
  border: none;
@@ -252,41 +251,54 @@ onUnmounted(() => {
252
251
  font-size: var(--marquee-scroller-control-font-size, 14px);
253
252
  transition: background-color var(--marquee-scroller-control-transition-duration, 0.2s);
254
253
 
254
+ & .input-button-icon {
255
+ margin: 0;
256
+ }
257
+
255
258
  &:hover {
256
259
  background-color: var(--marquee-scroller-control-background-colour-hover, rgba(0, 0, 0, 0.9));
257
260
  }
258
261
 
259
262
  &:focus-visible {
260
- outline: var(--marquee-scroller-focus-outline-width, 2px) solid var(--theme-ring);
261
- outline-offset: 2px;
263
+ outline: var(--_focus-outline);
264
+ outline-offset: var(--marquee-scroller-focus-outline-offset, 2px);
262
265
  }
263
266
  }
264
267
 
265
- .marquee-track {
268
+ .marquee-scroller-track {
266
269
  --_track-shift: calc(v-bind(itemCount) * (v-bind(width) + v-bind(gap)));
267
270
 
268
271
  display: flex;
269
272
  width: fit-content;
270
273
  gap: v-bind(gap);
271
- animation: marqueeMove v-bind(animationRuntime) linear infinite;
272
- }
274
+ animation: marquee-scroller-move v-bind(animationRuntime) linear infinite;
273
275
 
274
- &.reverse .marquee-track {
275
- animation-direction: reverse;
276
+ .marquee-scroller[data-reverse] & {
277
+ animation-direction: reverse;
278
+ }
279
+
280
+ .marquee-scroller[data-paused] &,
281
+ .marquee-scroller:hover & {
282
+ animation-play-state: paused;
283
+ }
284
+
285
+ .marquee-scroller[data-reduced-motion] & {
286
+ animation: none;
287
+ }
276
288
  }
277
289
 
278
- .marquee-group {
290
+ .marquee-scroller-group {
279
291
  display: flex;
280
292
  gap: v-bind(gap);
281
293
  flex-shrink: 0;
282
294
  }
283
295
 
284
- .item {
296
+ .marquee-scroller-item {
285
297
  width: v-bind(width);
286
298
  height: v-bind(height);
287
299
  display: grid;
288
300
  place-items: center;
289
- aspect-ratio: 1 / 1;
301
+ overflow-wrap: anywhere;
290
302
  transition: filter var(--marquee-scroller-item-transition-duration, 0.5s);
291
303
  flex-shrink: 0;
292
304
 
@@ -294,13 +306,17 @@ onUnmounted(() => {
294
306
  var(--marquee-scroller-item-border-colour, var(--slate-10));
295
307
  border-radius: var(--marquee-scroller-item-border-radius, 4px);
296
308
 
309
+ .marquee-scroller:hover & {
310
+ filter: var(--marquee-scroller-group-hover-filter, grayscale(1));
311
+ }
312
+
297
313
  &:hover {
298
314
  filter: var(--marquee-scroller-item-hover-filter, grayscale(0));
299
315
  }
300
316
  }
301
317
  }
302
318
 
303
- @keyframes marqueeMove {
319
+ @keyframes marquee-scroller-move {
304
320
  from {
305
321
  transform: translateX(0);
306
322
  }
@@ -310,21 +326,17 @@ onUnmounted(() => {
310
326
  }
311
327
  }
312
328
 
313
- /* High contrast mode support */
314
329
  @media (prefers-contrast: high) {
315
- .marquee-scroller {
316
- .control-btn {
317
- background: ButtonFace;
318
- color: ButtonText;
319
- border: var(--marquee-scroller-control-border-width, 1px) solid ButtonText;
320
- }
330
+ .marquee-scroller .marquee-scroller-control.input-button {
331
+ background: ButtonFace;
332
+ color: ButtonText;
333
+ border: var(--marquee-scroller-control-border-width, 1px) solid ButtonText;
321
334
  }
322
335
  }
323
336
 
324
- /* Respect user's motion preferences */
325
337
  @media (prefers-reduced-motion: reduce) {
326
- .marquee-scroller .marquee-track {
327
- animation: none !important;
338
+ .marquee-scroller .marquee-scroller-track {
339
+ animation: none;
328
340
  }
329
341
  }
330
342
  }
@@ -1,7 +1,8 @@
1
- import { computed } from "vue";
1
+ import { computed, ref } from "vue";
2
2
  import MarqueeScroller from "../MarqueeScroller.vue";
3
3
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
4
- import type { MarqueeItem, MarqueeItemConfig } from "~/types/components";
4
+ import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
5
+ import type { MarqueeItem, MarqueeItemConfig, MediaCanvas } from "~/types/components";
5
6
 
6
7
  // animationRuntime is a CSS duration string ("30s") on the component — Storybook has no native
7
8
  // range control for that, so the story exposes a plain-number `animationRuntimeSeconds` arg and
@@ -19,6 +20,7 @@ type StoryArgs = {
19
20
  pauseIcon?: string;
20
21
  playLabel?: string;
21
22
  pauseLabel?: string;
23
+ styleClassPassthrough?: string[];
22
24
  };
23
25
 
24
26
  const logoData: MarqueeItem[] = [
@@ -113,12 +115,31 @@ const meta: Meta<StoryArgs> = {
113
115
  description: "Control button aria-label while playing — override for localisation",
114
116
  table: { category: "Control button" },
115
117
  },
118
+ styleClassPassthrough: { control: "object", table: { category: "Styling" } },
116
119
  },
120
+ decorators: [
121
+ (story, context) => ({
122
+ components: { story, CanvasSwitcher },
123
+ setup() {
124
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
125
+ return { canvasName };
126
+ },
127
+ template: `
128
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
129
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
130
+ </div>
131
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
132
+ <story />
133
+ </div>
134
+ `,
135
+ }),
136
+ ],
117
137
  parameters: {
138
+ layout: "fullscreen",
118
139
  docs: {
119
140
  description: {
120
141
  component:
121
- "An infinite horizontal scroller for logos, badges, or other small repeating items. Items are provided via `marqueeData` plus one dynamically-named slot per item id. Pauses on hover/focus, supports keyboard control (spacebar), and respects prefers-reduced-motion.",
142
+ "An infinite horizontal scroller for logos, badges, or other small repeating items. Items come from `marqueeData` plus one dynamically named slot per item id; each slot renders in fixed `itemConfig` boxes, and the data repeats until it fills the container. Pauses on hover and whenever focus is inside it (including focusable content in an item), toggles with the spacebar on the region or the optional `show-controls` button, and respects prefers-reduced-motion. All copy (`aria-label`, `aria-description`, `play-label`, `pause-label`) is a prop for localisation.",
122
143
  },
123
144
  },
124
145
  },
@@ -136,6 +157,9 @@ export const WithControls: Story = {
136
157
  showControls: true,
137
158
  respectReducedMotion: true,
138
159
  },
160
+ parameters: {
161
+ docs: { description: { story: "Five placeholder logos with the visible pause/play button. Hover pauses the track and greys every item except the hovered one." } },
162
+ },
139
163
  render: (args) => ({
140
164
  components: { MarqueeScroller },
141
165
  setup() {
@@ -149,3 +173,108 @@ export const WithControls: Story = {
149
173
  `,
150
174
  }),
151
175
  };
176
+
177
+ const linkData: MarqueeItem[] = [
178
+ { id: 1, content: "Partner one" },
179
+ { id: 2, content: "Partner two" },
180
+ { id: 3, content: "Partner three" },
181
+ ];
182
+
183
+ export const FocusableItems: Story = {
184
+ args: {
185
+ animationRuntimeSeconds: 20,
186
+ marqueeData: linkData,
187
+ itemConfig: { width: "160px", height: "60px", gap: "24px" },
188
+ showControls: true,
189
+ ariaLabel: "Partner links",
190
+ },
191
+ render: (args) => ({
192
+ components: { MarqueeScroller },
193
+ setup() {
194
+ const { componentArgs } = useStorySetup(args as StoryArgs);
195
+ return { componentArgs, linkData };
196
+ },
197
+ template: `
198
+ <MarqueeScroller v-bind="componentArgs">
199
+ <template v-for="item in linkData" :key="item.id" #[item.id]>
200
+ <a href="#" style="font-size: 1.6rem;" @click.prevent>{{ item.content }}</a>
201
+ </template>
202
+ </MarqueeScroller>
203
+ `,
204
+ }),
205
+ parameters: {
206
+ docs: {
207
+ description: {
208
+ story:
209
+ "Links inside items. Tab into the scroller: it pauses as soon as focus is anywhere inside, the track stops being `aria-hidden`, and only the real copy of each link is reachable (the looping duplicate is `inert`). Tabbing out resumes it, unless you paused it yourself with the button or spacebar.",
210
+ },
211
+ },
212
+ },
213
+ };
214
+
215
+ const stressData: MarqueeItem[] = Array.from({ length: 24 }, (_, index) => ({ id: index + 1, content: `Item ${index + 1}` }));
216
+ const stressCopy = [
217
+ "Unternehmensberatungsgesellschaftmitbeschränkterhaftung",
218
+ "https://example.com/a/very/long/unbroken/url/that/never/ends",
219
+ "😀 Emoji first",
220
+ "<b>not bold</b>",
221
+ "شركة الشمال",
222
+ "",
223
+ ];
224
+
225
+ export const StressTest: Story = {
226
+ name: "Stress Test (Worst-Case Data)",
227
+ args: {
228
+ animationRuntimeSeconds: 8,
229
+ marqueeData: stressData,
230
+ itemConfig: { width: "90px", height: "70px", gap: "8px" },
231
+ showControls: true,
232
+ ariaLabel: "Bevorzugtekontaktmethodefürrückfragen 😀 <b>not bold</b> طريقة الاتصال",
233
+ ariaDescription: "Drücken Sie die Leertaste, um die Animation anzuhalten oder fortzusetzen.",
234
+ playLabel: "Animationfortsetzenundweiterlaufenlassen",
235
+ pauseLabel: "Animationanhaltenundstehenlassen",
236
+ },
237
+ render: (args) => ({
238
+ components: { MarqueeScroller },
239
+ setup() {
240
+ const { componentArgs } = useStorySetup(args as StoryArgs);
241
+ const single: MarqueeItem[] = [{ id: 1, content: "Only one" }];
242
+ const tiny: MarqueeItem[] = [{ id: 1, content: "a" }, { id: 2, content: "b" }];
243
+ return { componentArgs, args, stressCopy, single, tiny };
244
+ },
245
+ template: `
246
+ <div style="display: grid; gap: 2.4rem; font-size: 1.4rem;">
247
+ <p style="margin: 0;">24 items: unbroken German, a long URL, emoji, HTML-like and right-to-left text, an empty item and a broken image.</p>
248
+ <MarqueeScroller v-bind="componentArgs">
249
+ <template v-for="item in args.marqueeData" :key="item.id" #[item.id]>
250
+ <img v-if="item.id === 6" src="https://example.invalid/missing-logo.png" alt="Broken logo" width="60" height="40" />
251
+ <span v-else>{{ stressCopy[(item.id - 1) % stressCopy.length] }}</span>
252
+ </template>
253
+ </MarqueeScroller>
254
+
255
+ <p style="margin: 0;">A single item: repeats until it fills the container.</p>
256
+ <MarqueeScroller :marquee-data="single" :item-config="{ width: '120px', height: '50px', gap: '16px' }">
257
+ <template #1><span>Only one</span></template>
258
+ </MarqueeScroller>
259
+
260
+ <p style="margin: 0;">Tiny 4px items with no gap: the repeat count caps at 50 copies instead of filling the DOM.</p>
261
+ <MarqueeScroller :marquee-data="tiny" :item-config="{ width: '4px', height: '20px', gap: '0px' }">
262
+ <template #1><span></span></template>
263
+ <template #2><span></span></template>
264
+ </MarqueeScroller>
265
+
266
+ <p style="margin: 0;">Empty <code>marqueeData</code>: renders an empty region, no errors.</p>
267
+ <MarqueeScroller :marquee-data="[]"></MarqueeScroller>
268
+ </div>
269
+ `,
270
+ }),
271
+ parameters: {
272
+ initialCanvas: "mobileCanvas",
273
+ docs: {
274
+ description: {
275
+ story:
276
+ "Worst-case data across four scrollers. Check at every canvas width: unbroken text wraps inside its item box rather than spilling into the next one, markup renders as text, the broken image shows its alt text, the long localised labels stay inside the control button (they're its accessible name, visually hidden), the loop has no visible seam, and the empty scroller throws nothing. Item boxes are a fixed size, so slot content taller than `itemConfig.height` still overflows: size the content to the box.",
277
+ },
278
+ },
279
+ },
280
+ };