srcdev-nuxt-components 9.4.5 → 9.4.6

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 (45) hide show
  1. package/.claude/commands/migrate-component.md +40 -2
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/output.html +1 -1
  4. package/.claude/skills/components/breadcrumb.md +1 -0
  5. package/.claude/skills/components/content-docs.md +2 -0
  6. package/.claude/skills/components/display-tooltip-defined.md +3 -0
  7. package/.claude/skills/components/display-tooltip.md +1 -0
  8. package/.claude/skills/components/marquee-scroller.md +130 -0
  9. package/.claude/skills/components/navigation-items.md +1 -0
  10. package/.claude/skills/components/responsive-header.md +3 -0
  11. package/.claude/skills/components/site-header.md +4 -0
  12. package/.claude/skills/components/site-navigation.md +1 -0
  13. package/.claude/skills/components/tab-navigation.md +1 -0
  14. package/.claude/skills/index.md +1 -0
  15. package/.vscode/srcdev-component-breadcrumb.code-snippets +13 -0
  16. package/.vscode/srcdev-component-content-docs.code-snippets +18 -0
  17. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +15 -0
  18. package/.vscode/srcdev-component-display-tooltip.code-snippets +11 -0
  19. package/.vscode/srcdev-component-marquee-scroller.code-snippets +93 -0
  20. package/.vscode/srcdev-component-responsive-header.code-snippets +12 -0
  21. package/.vscode/srcdev-component-site-header.code-snippets +17 -0
  22. package/.vscode/srcdev-component-site-navigation.code-snippets +30 -0
  23. package/.vscode/srcdev-component-slider-gallery.code-snippets +38 -0
  24. package/.vscode/srcdev-component-tab-navigation.code-snippets +30 -0
  25. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +94 -0
  26. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +331 -0
  27. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +151 -0
  28. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +315 -0
  29. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +28 -0
  30. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +8 -2
  31. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +4 -1
  32. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +4 -1
  33. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +16 -3
  34. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  35. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +4 -1
  36. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +4 -1
  37. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +16 -4
  38. package/app/components/03.organisms/responsive-header/NavigationItems.vue +4 -1
  39. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +16 -3
  40. package/app/components/03.organisms/site-header/SiteHeader.vue +16 -1
  41. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  42. package/app/types/components/index.ts +1 -0
  43. package/app/types/components/marquee-scroller.d.ts +10 -0
  44. package/package.json +1 -1
  45. package/app/components/marquee-scroller/MarqueeScroller.vue +0 -289
@@ -0,0 +1,331 @@
1
+ <template>
2
+ <div
3
+ v-if="displayComponent"
4
+ ref="rootEl"
5
+ class="marquee-scroller"
6
+ :class="{ reverse: reverse, paused: isPaused, 'reduced-motion': prefersReducedMotion }"
7
+ role="region"
8
+ :aria-label="ariaLabel"
9
+ :aria-live="isPaused ? 'polite' : 'off'"
10
+ tabindex="0"
11
+ @keydown="handleKeydown"
12
+ @focus="handleFocus"
13
+ @blur="handleBlur"
14
+ >
15
+ <div class="sr-only">
16
+ {{ ariaDescription }}
17
+ </div>
18
+
19
+ <button
20
+ v-if="showControls"
21
+ class="control-btn"
22
+ :aria-label="isPaused ? playLabel : pauseLabel"
23
+ type="button"
24
+ @click="togglePause"
25
+ >
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">
33
+ <template v-for="copy in repeatCount" :key="copy">
34
+ <div v-for="item in marqueeData" :key="`${copy}-${item.id}`" class="item">
35
+ <slot :name="item.id"></slot>
36
+ </div>
37
+ </template>
38
+ </div>
39
+ <div class="marquee-group" aria-hidden="true">
40
+ <template v-for="copy in repeatCount" :key="copy">
41
+ <div v-for="item in marqueeData" :key="`duplicate-${copy}-${item.id}`" class="item">
42
+ <slot :name="item.id"></slot>
43
+ </div>
44
+ </template>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import type { MarqueeItem, MarqueeItemConfig } from "~/types/components";
52
+
53
+ interface Props {
54
+ animationRuntime?: string;
55
+ reverse?: boolean;
56
+ marqueeData?: MarqueeItem[];
57
+ itemConfig?: MarqueeItemConfig;
58
+ /** aria-label on the root region — override for localisation. */
59
+ ariaLabel?: string;
60
+ /** Screen-reader-only instructions rendered inside the region — override for localisation. */
61
+ ariaDescription?: string;
62
+ showControls?: boolean;
63
+ respectReducedMotion?: boolean;
64
+ /** Iconify icon name shown on the control button while paused. Ignored if the toggle-icon slot is used. */
65
+ playIcon?: string;
66
+ /** Iconify icon name shown on the control button while playing. Ignored if the toggle-icon slot is used. */
67
+ pauseIcon?: string;
68
+ /** Control button aria-label while paused — override for localisation. */
69
+ playLabel?: string;
70
+ /** Control button aria-label while playing — override for localisation. */
71
+ pauseLabel?: string;
72
+ }
73
+
74
+ const props = withDefaults(defineProps<Props>(), {
75
+ animationRuntime: "40s",
76
+ reverse: false,
77
+ marqueeData: () => [],
78
+ itemConfig: () => ({ width: "50px", height: "50px", gap: "16px" }),
79
+ ariaLabel: "Scrolling content",
80
+ ariaDescription: "Use spacebar to pause or play the animation.",
81
+ showControls: false,
82
+ respectReducedMotion: true,
83
+ playIcon: "mdi:play",
84
+ pauseIcon: "mdi:pause",
85
+ playLabel: "Play animation",
86
+ pauseLabel: "Pause animation",
87
+ });
88
+
89
+ const displayComponent = ref(false);
90
+ const isPaused = ref(false);
91
+ const isFocused = ref(false);
92
+ const prefersReducedMotion = ref(false);
93
+ const rootEl = ref<HTMLElement | null>(null);
94
+ const groupEl = ref<HTMLElement | null>(null);
95
+ const repeatCount = ref(1);
96
+ let resizeObserver: ResizeObserver | null = null;
97
+
98
+ const height = computed(() => props.itemConfig.height || "50px");
99
+ const width = computed(() => props.itemConfig.width || "50px");
100
+ const gap = computed(() => props.itemConfig.gap || "16px");
101
+ const itemCount = computed(() => props.marqueeData.length * repeatCount.value);
102
+
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.
106
+ const updateRepeatCount = () => {
107
+ if (!rootEl.value || !groupEl.value || props.marqueeData.length === 0) return;
108
+ const baseGroupWidth = groupEl.value.scrollWidth / repeatCount.value;
109
+ if (baseGroupWidth <= 0) return;
110
+ const needed = Math.max(1, Math.ceil(rootEl.value.offsetWidth / baseGroupWidth));
111
+ if (needed !== repeatCount.value) repeatCount.value = needed;
112
+ };
113
+
114
+ // Check for reduced motion preference
115
+ 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
+ }
128
+ };
129
+
130
+ const togglePause = () => {
131
+ isPaused.value = !isPaused.value;
132
+ };
133
+
134
+ const handleKeydown = (event: KeyboardEvent) => {
135
+ if (event.key === " " || event.key === "Spacebar") {
136
+ event.preventDefault();
137
+ togglePause();
138
+ }
139
+ };
140
+
141
+ const handleFocus = () => {
142
+ isFocused.value = true;
143
+ if (props.respectReducedMotion) {
144
+ isPaused.value = true;
145
+ }
146
+ };
147
+
148
+ const handleBlur = () => {
149
+ isFocused.value = false;
150
+ if (!prefersReducedMotion.value) {
151
+ isPaused.value = false;
152
+ }
153
+ };
154
+
155
+ watch(
156
+ () => [props.marqueeData, props.itemConfig],
157
+ () => {
158
+ repeatCount.value = 1;
159
+ nextTick(updateRepeatCount);
160
+ },
161
+ { deep: true }
162
+ );
163
+
164
+ onMounted(async () => {
165
+ displayComponent.value = true;
166
+ checkReducedMotion();
167
+
168
+ // Auto-pause if user prefers reduced motion
169
+ if (prefersReducedMotion.value) {
170
+ isPaused.value = true;
171
+ }
172
+
173
+ await nextTick();
174
+ updateRepeatCount();
175
+
176
+ if (typeof ResizeObserver !== "undefined" && rootEl.value) {
177
+ resizeObserver = new ResizeObserver(() => updateRepeatCount());
178
+ resizeObserver.observe(rootEl.value);
179
+ }
180
+ });
181
+
182
+ onUnmounted(() => {
183
+ resizeObserver?.disconnect();
184
+ });
185
+ </script>
186
+
187
+ <style lang="css">
188
+ @layer components {
189
+ .marquee-scroller {
190
+ --_fade-width: var(--marquee-scroller-fade-width, 10%);
191
+
192
+ width: 100%;
193
+ height: v-bind(height);
194
+ overflow: hidden;
195
+ mask-image: linear-gradient(
196
+ to right,
197
+ transparent,
198
+ #000 var(--_fade-width) calc(100% - var(--_fade-width)),
199
+ transparent
200
+ );
201
+ position: relative;
202
+
203
+ /* Focus styles */
204
+ &:focus-visible {
205
+ outline: var(--marquee-scroller-focus-outline-width, 2px) solid var(--theme-ring);
206
+ outline-offset: var(--marquee-scroller-focus-outline-offset, 2px);
207
+ }
208
+
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 {
229
+ position: absolute;
230
+ width: 1px;
231
+ height: 1px;
232
+ padding: 0;
233
+ margin: -1px;
234
+ overflow: hidden;
235
+ clip: rect(0, 0, 0, 0);
236
+ white-space: nowrap;
237
+ border: 0;
238
+ }
239
+
240
+ /* Control button */
241
+ .control-btn {
242
+ position: absolute;
243
+ inset-block-start: var(--marquee-scroller-control-offset, 8px);
244
+ inset-inline-end: var(--marquee-scroller-control-offset, 8px);
245
+ z-index: 10;
246
+ background: var(--marquee-scroller-control-background-colour, rgba(0, 0, 0, 0.7));
247
+ color: var(--marquee-scroller-control-text-colour, white);
248
+ border: none;
249
+ border-radius: var(--marquee-scroller-control-border-radius, 4px);
250
+ padding: var(--marquee-scroller-control-padding, 8px);
251
+ cursor: pointer;
252
+ font-size: var(--marquee-scroller-control-font-size, 14px);
253
+ transition: background-color var(--marquee-scroller-control-transition-duration, 0.2s);
254
+
255
+ &:hover {
256
+ background-color: var(--marquee-scroller-control-background-colour-hover, rgba(0, 0, 0, 0.9));
257
+ }
258
+
259
+ &:focus-visible {
260
+ outline: var(--marquee-scroller-focus-outline-width, 2px) solid var(--theme-ring);
261
+ outline-offset: 2px;
262
+ }
263
+ }
264
+
265
+ .marquee-track {
266
+ --_track-shift: calc(v-bind(itemCount) * (v-bind(width) + v-bind(gap)));
267
+
268
+ display: flex;
269
+ width: fit-content;
270
+ gap: v-bind(gap);
271
+ animation: marqueeMove v-bind(animationRuntime) linear infinite;
272
+ }
273
+
274
+ &.reverse .marquee-track {
275
+ animation-direction: reverse;
276
+ }
277
+
278
+ .marquee-group {
279
+ display: flex;
280
+ gap: v-bind(gap);
281
+ flex-shrink: 0;
282
+ }
283
+
284
+ .item {
285
+ width: v-bind(width);
286
+ height: v-bind(height);
287
+ display: grid;
288
+ place-items: center;
289
+ aspect-ratio: 1 / 1;
290
+ transition: filter var(--marquee-scroller-item-transition-duration, 0.5s);
291
+ flex-shrink: 0;
292
+
293
+ border: var(--marquee-scroller-item-border-width, 1px) solid
294
+ var(--marquee-scroller-item-border-colour, light-dark(var(--slate-10), var(--slate-00)));
295
+ border-radius: var(--marquee-scroller-item-border-radius, 4px);
296
+
297
+ &:hover {
298
+ filter: var(--marquee-scroller-item-hover-filter, grayscale(0));
299
+ }
300
+ }
301
+ }
302
+
303
+ @keyframes marqueeMove {
304
+ from {
305
+ transform: translateX(0);
306
+ }
307
+ to {
308
+ /* Not -50%: the track's own gap between the two groups makes that overshoot the seam. */
309
+ transform: translateX(calc(-1 * var(--_track-shift)));
310
+ }
311
+ }
312
+
313
+ /* High contrast mode support */
314
+ @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
+ }
321
+ }
322
+ }
323
+
324
+ /* Respect user's motion preferences */
325
+ @media (prefers-reduced-motion: reduce) {
326
+ .marquee-scroller .marquee-track {
327
+ animation: none !important;
328
+ }
329
+ }
330
+ }
331
+ </style>
@@ -0,0 +1,151 @@
1
+ import { computed } from "vue";
2
+ import MarqueeScroller from "../MarqueeScroller.vue";
3
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
4
+ import type { MarqueeItem, MarqueeItemConfig } from "~/types/components";
5
+
6
+ // animationRuntime is a CSS duration string ("30s") on the component — Storybook has no native
7
+ // range control for that, so the story exposes a plain-number `animationRuntimeSeconds` arg and
8
+ // maps it to the real prop. See feedback_storybook_extra_args memory / StoryArgs pattern.
9
+ type StoryArgs = {
10
+ animationRuntimeSeconds?: number;
11
+ reverse?: boolean;
12
+ marqueeData?: MarqueeItem[];
13
+ itemConfig?: MarqueeItemConfig;
14
+ ariaLabel?: string;
15
+ ariaDescription?: string;
16
+ showControls?: boolean;
17
+ respectReducedMotion?: boolean;
18
+ playIcon?: string;
19
+ pauseIcon?: string;
20
+ playLabel?: string;
21
+ pauseLabel?: string;
22
+ };
23
+
24
+ const logoData: MarqueeItem[] = [
25
+ { id: 1, content: "Logo A" },
26
+ { id: 2, content: "Logo B" },
27
+ { id: 3, content: "Logo C" },
28
+ { id: 4, content: "Logo D" },
29
+ { id: 5, content: "Logo E" },
30
+ ];
31
+
32
+ const logoSlots = logoData
33
+ .map(
34
+ (item) => `
35
+ <template #${item.id}>
36
+ <div style="display: grid; place-items: center; width: 100%; height: 100%; background: light-dark(#eee, #333); font-weight: 600;">
37
+ ${item.content}
38
+ </div>
39
+ </template>`
40
+ )
41
+ .join("\n");
42
+
43
+ function useStorySetup(args: StoryArgs) {
44
+ const componentArgs = computed(() => {
45
+ const { animationRuntimeSeconds, ...rest } = args;
46
+ return {
47
+ ...rest,
48
+ animationRuntime: `${animationRuntimeSeconds ?? 30}s`,
49
+ };
50
+ });
51
+ return { componentArgs };
52
+ }
53
+
54
+ const meta: Meta<StoryArgs> = {
55
+ title: "Atoms/Effects/MarqueeScroller",
56
+ component: MarqueeScroller,
57
+ argTypes: {
58
+ animationRuntimeSeconds: {
59
+ control: { type: "range", min: 5, max: 90, step: 1 },
60
+ description: "Loop duration in seconds — maps to the `animationRuntime` prop (e.g. `30s`)",
61
+ table: { category: "Animation" },
62
+ },
63
+ reverse: {
64
+ control: "boolean",
65
+ description: "Reverses the scroll direction",
66
+ table: { category: "Animation" },
67
+ },
68
+ marqueeData: {
69
+ control: "object",
70
+ description: "Array of `{ id, content }` items — each `id` names a slot to fill with that item's markup",
71
+ table: { category: "Content" },
72
+ },
73
+ itemConfig: {
74
+ control: "object",
75
+ description: "`{ width, height, gap }` applied to every item and the track",
76
+ table: { category: "Layout" },
77
+ },
78
+ ariaLabel: {
79
+ control: "text",
80
+ table: { category: "Accessibility" },
81
+ },
82
+ ariaDescription: {
83
+ control: "text",
84
+ table: { category: "Accessibility" },
85
+ },
86
+ showControls: {
87
+ control: "boolean",
88
+ description: "Shows a visible pause/play button (required for WCAG 2.2.2 on any auto-scrolling content)",
89
+ table: { category: "Accessibility" },
90
+ },
91
+ respectReducedMotion: {
92
+ control: "boolean",
93
+ description: "Auto-pauses and disables the animation when the user has prefers-reduced-motion set",
94
+ table: { category: "Accessibility" },
95
+ },
96
+ playIcon: {
97
+ control: "text",
98
+ description: "Iconify icon name shown on the control button while paused",
99
+ table: { category: "Control button" },
100
+ },
101
+ pauseIcon: {
102
+ control: "text",
103
+ description: "Iconify icon name shown on the control button while playing",
104
+ table: { category: "Control button" },
105
+ },
106
+ playLabel: {
107
+ control: "text",
108
+ description: "Control button aria-label while paused — override for localisation",
109
+ table: { category: "Control button" },
110
+ },
111
+ pauseLabel: {
112
+ control: "text",
113
+ description: "Control button aria-label while playing — override for localisation",
114
+ table: { category: "Control button" },
115
+ },
116
+ },
117
+ parameters: {
118
+ docs: {
119
+ description: {
120
+ 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.",
122
+ },
123
+ },
124
+ },
125
+ };
126
+
127
+ export default meta;
128
+ type Story = StoryObj<StoryArgs>;
129
+
130
+ export const WithControls: Story = {
131
+ args: {
132
+ animationRuntimeSeconds: 30,
133
+ reverse: false,
134
+ marqueeData: logoData,
135
+ itemConfig: { width: "120px", height: "60px", gap: "24px" },
136
+ showControls: true,
137
+ respectReducedMotion: true,
138
+ },
139
+ render: (args) => ({
140
+ components: { MarqueeScroller },
141
+ setup() {
142
+ const { componentArgs } = useStorySetup(args as StoryArgs);
143
+ return { componentArgs };
144
+ },
145
+ template: `
146
+ <MarqueeScroller v-bind="componentArgs">
147
+ ${logoSlots}
148
+ </MarqueeScroller>
149
+ `,
150
+ }),
151
+ };