srcdev-nuxt-components 9.4.7 → 9.4.9

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 (49) hide show
  1. package/.claude/component-ledger/audit.json +1 -1
  2. package/.claude/component-ledger/output.html +1 -1
  3. package/.claude/skills/component-dynamic-slots.md +1 -1
  4. package/.claude/skills/components/header-block.md +92 -0
  5. package/.claude/skills/components/rotating-carousel-image.md +101 -0
  6. package/.claude/skills/components/skip-links.md +82 -0
  7. package/.claude/skills/components/tabs-core.md +187 -0
  8. package/.claude/skills/index.md +4 -0
  9. package/.vscode/srcdev-component-header-block.code-snippets +41 -0
  10. package/.vscode/srcdev-component-rotating-carousel-image.code-snippets +74 -0
  11. package/.vscode/srcdev-component-skip-links.code-snippets +55 -0
  12. package/.vscode/srcdev-component-tabs-core.code-snippets +78 -0
  13. package/app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css +2 -1
  14. package/app/components/01.atoms/animations/rotating-carousel-image/CONSUMER-STYLING.md +54 -0
  15. package/app/components/01.atoms/animations/rotating-carousel-image/RotatingCarouselImage.vue +315 -0
  16. package/app/components/01.atoms/animations/rotating-carousel-image/stories/RotatingCarouselImage.stories.ts +156 -0
  17. package/app/components/01.atoms/animations/rotating-carousel-image/tests/RotatingCarouselImage.spec.ts +230 -0
  18. package/app/components/01.atoms/animations/rotating-carousel-image/tests/__snapshots__/RotatingCarouselImage.spec.ts.snap +19 -0
  19. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +31 -0
  20. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +98 -0
  21. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +113 -0
  22. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +71 -0
  23. package/app/components/01.atoms/navigation/skip-links/tests/__snapshots__/SkipLinks.spec.ts.snap +15 -0
  24. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +99 -0
  25. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +272 -0
  26. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +199 -0
  27. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +274 -0
  28. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +43 -0
  29. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +50 -0
  30. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +111 -0
  31. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +119 -0
  32. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +5 -0
  33. package/app/components/03.organisms/site-header/SiteHeader.vue +1 -1
  34. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +1 -1
  35. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +3 -3
  36. package/app/composables/useTabs.ts +225 -207
  37. package/app/types/components/index.ts +2 -0
  38. package/app/types/components/rotating-carousel-image.d.ts +4 -0
  39. package/app/types/components/skip-links.d.ts +4 -0
  40. package/package.json +1 -1
  41. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +0 -11
  42. package/app/components/rotating-carousel/RotatingCarouselImage.vue +0 -216
  43. package/app/components/skip-links/SkipLinks.vue +0 -60
  44. package/app/components/tabs/TabsCore.vue +0 -306
  45. package/app/components/typography/HeaderBlock.vue +0 -35
  46. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/AutoGrid.vue +0 -0
  47. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/stories/AutoGrid.stories.ts +0 -0
  48. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/AutoGrid.spec.ts +0 -0
  49. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/__snapshots__/AutoGrid.spec.ts.snap +0 -0
@@ -0,0 +1,315 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ ref="carouselRef"
5
+ class="rotating-carousel"
6
+ :class="[elementClasses, { paused: isPaused, 'reduced-motion': prefersReducedMotion, 'hover-pauses': props.pauseOnHover }]"
7
+ :style="`--_rotate-x: ${rotateXProp}deg; --_perspective: ${perspectiveProp}; --_translateZ: ${translateZProp}`"
8
+ role="region"
9
+ :aria-label="ariaLabel"
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
+ type="button"
23
+ :aria-label="isPaused ? playLabel : pauseLabel"
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="slider" :style="`--_quantity: ${data.length}`">
32
+ <div v-for="(item, key) in data" :key="key" class="item" :style="`--_position: ${key}`">
33
+ <NuxtImg :src="item.src" :alt="item.alt" />
34
+ </div>
35
+ </div>
36
+ </component>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import type { CarouselImageData } from "~/types/components"
41
+
42
+ interface Props {
43
+ data?: CarouselImageData[]
44
+ tag?:
45
+ | "div"
46
+ | "p"
47
+ | "span"
48
+ | "section"
49
+ | "article"
50
+ | "aside"
51
+ | "header"
52
+ | "footer"
53
+ | "main"
54
+ | "nav"
55
+ | "ul"
56
+ | "ol"
57
+ rotateX?: number
58
+ perspective?: number
59
+ translateZ?: number
60
+ /** Pauses the rotation while the pointer hovers over the carousel. */
61
+ pauseOnHover?: boolean
62
+ /** Drives rotateX from scroll position instead of the static `rotateX` prop. */
63
+ useParallaxEffect?: boolean
64
+ showControls?: boolean
65
+ respectReducedMotion?: boolean
66
+ ariaLabel?: string
67
+ ariaDescription?: string
68
+ playIcon?: string
69
+ pauseIcon?: string
70
+ playLabel?: string
71
+ pauseLabel?: string
72
+ styleClassPassthrough?: string | string[]
73
+ }
74
+
75
+ const props = withDefaults(defineProps<Props>(), {
76
+ data: () => [],
77
+ tag: "div",
78
+ rotateX: 0,
79
+ perspective: 1000,
80
+ translateZ: 1000,
81
+ pauseOnHover: false,
82
+ useParallaxEffect: true,
83
+ showControls: false,
84
+ respectReducedMotion: true,
85
+ ariaLabel: "Rotating image carousel",
86
+ ariaDescription: "Use spacebar to pause or play the rotation.",
87
+ playIcon: "mdi:play",
88
+ pauseIcon: "mdi:pause",
89
+ playLabel: "Play rotation",
90
+ pauseLabel: "Pause rotation",
91
+ styleClassPassthrough: () => [],
92
+ })
93
+
94
+ const perspectiveProp = computed(() => `${props.perspective.toString()}px`)
95
+ const translateZProp = computed(() => `${props.translateZ.toString()}px`)
96
+
97
+ const carouselRef = ref<HTMLElement | null>(null)
98
+ const rotateXProp = ref(props.rotateX)
99
+ const minRotateX = -32
100
+ const maxRotateX = 32
101
+
102
+ const isPaused = ref(false)
103
+ const prefersReducedMotion = ref(false)
104
+
105
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
106
+
107
+ watch(
108
+ () => props.styleClassPassthrough,
109
+ () => {
110
+ resetElementClasses(props.styleClassPassthrough)
111
+ }
112
+ )
113
+
114
+ watch(
115
+ () => props.rotateX,
116
+ () => {
117
+ if (!props.useParallaxEffect) {
118
+ rotateXProp.value = props.rotateX
119
+ }
120
+ }
121
+ )
122
+
123
+ watch(
124
+ () => props.useParallaxEffect,
125
+ (currentValue) => {
126
+ if (currentValue) {
127
+ handleScroll()
128
+ window.addEventListener("scroll", handleScroll)
129
+ } else {
130
+ window.removeEventListener("scroll", handleScroll)
131
+ }
132
+ }
133
+ )
134
+
135
+ const handleScroll = () => {
136
+ if (!carouselRef.value) return
137
+ const rect = (carouselRef.value as HTMLElement).getBoundingClientRect()
138
+ const viewportHeight = window.innerHeight
139
+
140
+ const elementCenter = rect.top + rect.height / 2
141
+ const viewportCenter = viewportHeight / 2
142
+ const distanceFromCenter = viewportCenter - elementCenter
143
+ const maxDistance = viewportHeight / 2 + rect.height / 2
144
+
145
+ const progress = (distanceFromCenter + maxDistance) / (maxDistance * 2)
146
+ const clampedProgress = Math.max(0, Math.min(1, progress))
147
+
148
+ rotateXProp.value = minRotateX + (maxRotateX - minRotateX) * clampedProgress
149
+ }
150
+
151
+ const togglePause = () => {
152
+ isPaused.value = !isPaused.value
153
+ }
154
+
155
+ const handleKeydown = (event: KeyboardEvent) => {
156
+ if (event.key === " " || event.key === "Spacebar") {
157
+ event.preventDefault()
158
+ togglePause()
159
+ }
160
+ }
161
+
162
+ const handleFocus = () => {
163
+ isPaused.value = true
164
+ }
165
+
166
+ const handleBlur = () => {
167
+ if (!prefersReducedMotion.value) {
168
+ isPaused.value = false
169
+ }
170
+ }
171
+
172
+ const checkReducedMotion = () => {
173
+ if (typeof window !== "undefined" && props.respectReducedMotion) {
174
+ const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
175
+ prefersReducedMotion.value = mediaQuery.matches
176
+ if (mediaQuery.matches) {
177
+ isPaused.value = true
178
+ }
179
+
180
+ mediaQuery.addEventListener("change", (event) => {
181
+ prefersReducedMotion.value = event.matches
182
+ if (event.matches) {
183
+ isPaused.value = true
184
+ }
185
+ })
186
+ }
187
+ }
188
+
189
+ onMounted(async () => {
190
+ checkReducedMotion()
191
+
192
+ if (props.useParallaxEffect) {
193
+ handleScroll()
194
+ await nextTick()
195
+ window.addEventListener("scroll", handleScroll)
196
+ }
197
+ })
198
+
199
+ onUnmounted(() => {
200
+ window.removeEventListener("scroll", handleScroll)
201
+ })
202
+ </script>
203
+
204
+ <style lang="css">
205
+ @layer components {
206
+ @keyframes autoRun {
207
+ from {
208
+ transform: perspective(var(--_perspective)) rotateX(var(--_rotate-x)) rotateY(0deg);
209
+ }
210
+ to {
211
+ transform: perspective(var(--_perspective)) rotateX(var(--_rotate-x)) rotateY(360deg);
212
+ }
213
+ }
214
+
215
+ .rotating-carousel {
216
+ width: 100%;
217
+ height: var(--rotating-carousel-height, 70svh);
218
+ text-align: center;
219
+ overflow: hidden;
220
+ position: relative;
221
+
222
+ &:focus-visible {
223
+ outline: var(--rotating-carousel-focus-outline-width, 2px) solid var(--theme-ring);
224
+ outline-offset: var(--rotating-carousel-focus-outline-offset, 2px);
225
+ }
226
+
227
+ &.paused .slider {
228
+ animation-play-state: paused;
229
+ }
230
+
231
+ &.reduced-motion .slider {
232
+ animation: none !important;
233
+ }
234
+
235
+ .sr-only {
236
+ position: absolute;
237
+ width: 1px;
238
+ height: 1px;
239
+ padding: 0;
240
+ margin: -1px;
241
+ overflow: hidden;
242
+ clip: rect(0, 0, 0, 0);
243
+ white-space: nowrap;
244
+ border: 0;
245
+ }
246
+
247
+ .control-btn {
248
+ position: absolute;
249
+ inset-block-start: var(--rotating-carousel-control-offset, 8px);
250
+ inset-inline-end: var(--rotating-carousel-control-offset, 8px);
251
+ z-index: 10;
252
+ background: var(--rotating-carousel-control-background-colour, rgba(0, 0, 0, 0.7));
253
+ color: var(--rotating-carousel-control-text-colour, white);
254
+ border: none;
255
+ border-radius: var(--rotating-carousel-control-border-radius, 4px);
256
+ padding: var(--rotating-carousel-control-padding, 8px);
257
+ cursor: pointer;
258
+ font-size: var(--rotating-carousel-control-font-size, 14px);
259
+ transition: background-color var(--rotating-carousel-control-transition-duration, 0.2s);
260
+
261
+ &:hover {
262
+ background-color: var(--rotating-carousel-control-background-colour-hover, rgba(0, 0, 0, 0.9));
263
+ }
264
+
265
+ &:focus-visible {
266
+ outline: var(--rotating-carousel-focus-outline-width, 2px) solid var(--theme-ring);
267
+ outline-offset: var(--rotating-carousel-focus-outline-offset, 2px);
268
+ }
269
+ }
270
+
271
+ .slider {
272
+ position: absolute;
273
+ width: var(--rotating-carousel-item-width, 200px);
274
+ height: var(--rotating-carousel-item-height, 250px);
275
+ bottom: var(--rotating-carousel-offset-bottom, 35%);
276
+ left: calc(50% - (var(--rotating-carousel-item-width, 200px) / 2));
277
+ transform-style: preserve-3d;
278
+ transform: perspective(var(--_perspective));
279
+ animation: autoRun var(--rotating-carousel-rotation-duration, 30s) linear infinite;
280
+ z-index: var(--rotating-carousel-z-index, 2);
281
+
282
+ .item {
283
+ position: absolute;
284
+ inset: 0 0 0 0;
285
+ transform: rotateY(calc((var(--_position) - 1) * (360 / var(--_quantity)) * 1deg))
286
+ translateZ(var(--_translateZ));
287
+
288
+ img {
289
+ width: 100%;
290
+ height: 100%;
291
+ object-fit: cover;
292
+ }
293
+ }
294
+ }
295
+
296
+ &.hover-pauses:hover .slider {
297
+ animation-play-state: paused;
298
+ }
299
+ }
300
+
301
+ @media (prefers-contrast: high) {
302
+ .rotating-carousel .control-btn {
303
+ background: ButtonFace;
304
+ color: ButtonText;
305
+ border: var(--rotating-carousel-control-border-width, 1px) solid ButtonText;
306
+ }
307
+ }
308
+
309
+ @media (prefers-reduced-motion: reduce) {
310
+ .rotating-carousel .slider {
311
+ animation: none !important;
312
+ }
313
+ }
314
+ }
315
+ </style>
@@ -0,0 +1,156 @@
1
+ import RotatingCarouselImage from "../RotatingCarouselImage.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import type { CarouselImageData } from "~/types/components";
4
+
5
+ type StoryArgs = {
6
+ data?: CarouselImageData[];
7
+ rotateX?: number;
8
+ perspective?: number;
9
+ translateZ?: number;
10
+ pauseOnHover?: boolean;
11
+ useParallaxEffect?: boolean;
12
+ showControls?: boolean;
13
+ respectReducedMotion?: boolean;
14
+ ariaLabel?: string;
15
+ ariaDescription?: string;
16
+ playIcon?: string;
17
+ pauseIcon?: string;
18
+ playLabel?: string;
19
+ pauseLabel?: string;
20
+ };
21
+
22
+ const carouselData: CarouselImageData[] = Array.from({ length: 8 }, (_, index) => ({
23
+ src: `https://picsum.photos/seed/rotating-carousel-${index}/400/500`,
24
+ alt: `Rotating carousel image ${index + 1}`,
25
+ }));
26
+
27
+ const meta: Meta<StoryArgs> = {
28
+ title: "Atoms/Animations/RotatingCarouselImage",
29
+ component: RotatingCarouselImage,
30
+ argTypes: {
31
+ data: {
32
+ control: "object",
33
+ description: "Array of `{ src, alt }` images rendered around the rotation",
34
+ table: { category: "Content" },
35
+ },
36
+ rotateX: {
37
+ control: { type: "range", min: -32, max: 32, step: 1 },
38
+ description: "Static X-axis tilt in degrees — ignored while `useParallaxEffect` is true",
39
+ table: { category: "Rotation" },
40
+ },
41
+ perspective: {
42
+ control: { type: "range", min: 200, max: 3000, step: 50 },
43
+ description: "CSS perspective distance in pixels",
44
+ table: { category: "Rotation" },
45
+ },
46
+ translateZ: {
47
+ control: { type: "range", min: 200, max: 3000, step: 50 },
48
+ description: "Radius of the rotation — distance each item sits from the centre, in pixels",
49
+ table: { category: "Rotation" },
50
+ },
51
+ pauseOnHover: {
52
+ control: "boolean",
53
+ description: "Pauses the rotation while the pointer hovers over the carousel",
54
+ table: { category: "Interaction" },
55
+ },
56
+ useParallaxEffect: {
57
+ control: "boolean",
58
+ description: "Drives the tilt from scroll position instead of the static `rotateX` prop",
59
+ table: { category: "Interaction" },
60
+ },
61
+ showControls: {
62
+ control: "boolean",
63
+ description: "Shows a visible pause/play button (required for WCAG 2.2.2 on any auto-rotating content)",
64
+ table: { category: "Accessibility" },
65
+ },
66
+ respectReducedMotion: {
67
+ control: "boolean",
68
+ description: "Auto-pauses and disables the animation when the user has prefers-reduced-motion set",
69
+ table: { category: "Accessibility" },
70
+ },
71
+ ariaLabel: {
72
+ control: "text",
73
+ table: { category: "Accessibility" },
74
+ },
75
+ ariaDescription: {
76
+ control: "text",
77
+ table: { category: "Accessibility" },
78
+ },
79
+ playIcon: {
80
+ control: "text",
81
+ description: "Iconify icon name shown on the control button while paused",
82
+ table: { category: "Control button" },
83
+ },
84
+ pauseIcon: {
85
+ control: "text",
86
+ description: "Iconify icon name shown on the control button while playing",
87
+ table: { category: "Control button" },
88
+ },
89
+ playLabel: {
90
+ control: "text",
91
+ description: "Control button aria-label while paused — override for localisation",
92
+ table: { category: "Control button" },
93
+ },
94
+ pauseLabel: {
95
+ control: "text",
96
+ description: "Control button aria-label while playing — override for localisation",
97
+ table: { category: "Control button" },
98
+ },
99
+ },
100
+ parameters: {
101
+ docs: {
102
+ description: {
103
+ component:
104
+ "A 3D rotating carousel of images. By default the tilt follows scroll position (`useParallaxEffect`); disable it to use a static `rotateX`. Supports keyboard pause (spacebar), an optional visible pause/play button, and respects prefers-reduced-motion.",
105
+ },
106
+ },
107
+ },
108
+ };
109
+
110
+ export default meta;
111
+ type Story = StoryObj<StoryArgs>;
112
+
113
+ export const WithControls: Story = {
114
+ args: {
115
+ data: carouselData,
116
+ rotateX: 0,
117
+ perspective: 1000,
118
+ translateZ: 1000,
119
+ pauseOnHover: false,
120
+ useParallaxEffect: false,
121
+ showControls: true,
122
+ respectReducedMotion: true,
123
+ ariaLabel: "Rotating image carousel",
124
+ },
125
+ render: (args) => ({
126
+ components: { RotatingCarouselImage },
127
+ setup() {
128
+ return { args };
129
+ },
130
+ template: `<RotatingCarouselImage v-bind="args" />`,
131
+ }),
132
+ };
133
+
134
+ export const ScrollParallax: Story = {
135
+ args: {
136
+ data: carouselData,
137
+ perspective: 1000,
138
+ translateZ: 1000,
139
+ useParallaxEffect: true,
140
+ showControls: true,
141
+ },
142
+ render: (args) => ({
143
+ components: { RotatingCarouselImage },
144
+ setup() {
145
+ return { args };
146
+ },
147
+ template: `
148
+ <div>
149
+ <p style="text-align: center; padding: 2rem;">Scroll this preview to tilt the carousel.</p>
150
+ <div style="height: 60svh;"></div>
151
+ <RotatingCarouselImage v-bind="args" />
152
+ <div style="height: 60svh;"></div>
153
+ </div>
154
+ `,
155
+ }),
156
+ };
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import RotatingCarouselImage from "../RotatingCarouselImage.vue";
4
+
5
+ const stubMatchMedia = (prefersReducedMotion: boolean) => {
6
+ vi.stubGlobal(
7
+ "matchMedia",
8
+ vi.fn().mockReturnValue({
9
+ matches: prefersReducedMotion,
10
+ addEventListener: vi.fn(),
11
+ })
12
+ );
13
+ };
14
+
15
+ const carouselData = [
16
+ { src: "/a.jpg", alt: "A" },
17
+ { src: "/b.jpg", alt: "B" },
18
+ ];
19
+
20
+ describe("RotatingCarouselImage", () => {
21
+ beforeEach(() => {
22
+ stubMatchMedia(false);
23
+ });
24
+
25
+ // ─── Mount ───────────────────────────────────────────────────────────────
26
+
27
+ it("mounts without error", async () => {
28
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
29
+ props: { useParallaxEffect: false },
30
+ });
31
+ expect(wrapper.vm).toBeTruthy();
32
+ });
33
+
34
+ it("renders correct HTML structure (default props)", async () => {
35
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
36
+ props: { useParallaxEffect: false },
37
+ });
38
+ expect(wrapper.html()).toMatchSnapshot();
39
+ });
40
+
41
+ it("renders correct HTML structure (all props set)", async () => {
42
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
43
+ props: {
44
+ data: carouselData,
45
+ tag: "section",
46
+ rotateX: 10,
47
+ perspective: 1200,
48
+ translateZ: 1200,
49
+ pauseOnHover: true,
50
+ useParallaxEffect: false,
51
+ showControls: true,
52
+ ariaLabel: "Custom carousel",
53
+ ariaDescription: "Custom instructions",
54
+ },
55
+ });
56
+ expect(wrapper.html()).toMatchSnapshot();
57
+ });
58
+
59
+ // ─── data / rendering ────────────────────────────────────────────────────
60
+
61
+ it("renders one .item per data entry", async () => {
62
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
63
+ props: { data: carouselData, useParallaxEffect: false },
64
+ });
65
+ expect(wrapper.findAll(".item")).toHaveLength(2);
66
+ });
67
+
68
+ it("renders no items when data is empty", async () => {
69
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
70
+ props: { useParallaxEffect: false },
71
+ });
72
+ expect(wrapper.findAll(".item")).toHaveLength(0);
73
+ });
74
+
75
+ it("renders the given tag", async () => {
76
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
77
+ props: { tag: "section", useParallaxEffect: false },
78
+ });
79
+ expect(wrapper.element.tagName).toBe("SECTION");
80
+ });
81
+
82
+ // ─── Accessibility attributes ────────────────────────────────────────────
83
+
84
+ it("defaults aria-label", async () => {
85
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
86
+ props: { useParallaxEffect: false },
87
+ });
88
+ expect(wrapper.attributes("aria-label")).toBe("Rotating image carousel");
89
+ });
90
+
91
+ it("uses a custom ariaLabel when provided", async () => {
92
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
93
+ props: { ariaLabel: "Client photos", useParallaxEffect: false },
94
+ });
95
+ expect(wrapper.attributes("aria-label")).toBe("Client photos");
96
+ });
97
+
98
+ it("uses default screen-reader instructions when ariaDescription is not set", async () => {
99
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
100
+ props: { useParallaxEffect: false },
101
+ });
102
+ expect(wrapper.find(".sr-only").text()).toContain("Use spacebar to pause or play");
103
+ });
104
+
105
+ it("uses a custom ariaDescription when provided", async () => {
106
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
107
+ props: { ariaDescription: "Custom instructions", useParallaxEffect: false },
108
+ });
109
+ expect(wrapper.find(".sr-only").text()).toBe("Custom instructions");
110
+ });
111
+
112
+ // ─── Controls ────────────────────────────────────────────────────────────
113
+
114
+ it("does not render the control button by default", async () => {
115
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
116
+ props: { useParallaxEffect: false },
117
+ });
118
+ expect(wrapper.find(".control-btn").exists()).toBe(false);
119
+ });
120
+
121
+ it("renders the control button when showControls is true", async () => {
122
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
123
+ props: { showControls: true, useParallaxEffect: false },
124
+ });
125
+ expect(wrapper.find(".control-btn").exists()).toBe(true);
126
+ });
127
+
128
+ it("toggles paused state when the control button is clicked", async () => {
129
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
130
+ props: { showControls: true, useParallaxEffect: false },
131
+ });
132
+ await wrapper.find(".control-btn").trigger("click");
133
+ expect(wrapper.classes()).toContain("paused");
134
+ expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Play rotation");
135
+ });
136
+
137
+ it("renders a custom playIcon/pauseIcon", async () => {
138
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
139
+ props: { showControls: true, useParallaxEffect: false, playIcon: "mdi:play-circle", pauseIcon: "mdi:pause-circle" },
140
+ });
141
+ expect(wrapper.find(".control-btn").html()).toContain("mdi:pause-circle");
142
+ await wrapper.find(".control-btn").trigger("click");
143
+ expect(wrapper.find(".control-btn").html()).toContain("mdi:play-circle");
144
+ });
145
+
146
+ it("uses custom playLabel/pauseLabel for the control button's aria-label", async () => {
147
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
148
+ props: { showControls: true, useParallaxEffect: false, playLabel: "Reproduire", pauseLabel: "Suspendre" },
149
+ });
150
+ expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Suspendre");
151
+ await wrapper.find(".control-btn").trigger("click");
152
+ expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Reproduire");
153
+ });
154
+
155
+ it("replaces the toggle icon via the toggle-icon slot", async () => {
156
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
157
+ props: { showControls: true, useParallaxEffect: false },
158
+ slots: { "toggle-icon": "<span class='custom-icon'>custom</span>" },
159
+ });
160
+ expect(wrapper.find(".custom-icon").exists()).toBe(true);
161
+ });
162
+
163
+ // ─── Keyboard interaction ────────────────────────────────────────────────
164
+
165
+ it("toggles pause on spacebar keydown", async () => {
166
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
167
+ props: { useParallaxEffect: false },
168
+ });
169
+ await wrapper.trigger("keydown", { key: " " });
170
+ expect(wrapper.classes()).toContain("paused");
171
+ await wrapper.trigger("keydown", { key: " " });
172
+ expect(wrapper.classes()).not.toContain("paused");
173
+ });
174
+
175
+ // ─── Focus / blur ────────────────────────────────────────────────────────
176
+
177
+ it("pauses on focus", async () => {
178
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
179
+ props: { useParallaxEffect: false },
180
+ });
181
+ await wrapper.trigger("focus");
182
+ expect(wrapper.classes()).toContain("paused");
183
+ });
184
+
185
+ it("resumes on blur when the user does not prefer reduced motion", async () => {
186
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
187
+ props: { useParallaxEffect: false },
188
+ });
189
+ await wrapper.trigger("focus");
190
+ await wrapper.trigger("blur");
191
+ expect(wrapper.classes()).not.toContain("paused");
192
+ });
193
+
194
+ // ─── pauseOnHover ────────────────────────────────────────────────────────
195
+
196
+ it("does not apply the hover-pauses class by default", async () => {
197
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
198
+ props: { useParallaxEffect: false },
199
+ });
200
+ expect(wrapper.classes()).not.toContain("hover-pauses");
201
+ });
202
+
203
+ it("applies the hover-pauses class when pauseOnHover is true", async () => {
204
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
205
+ props: { pauseOnHover: true, useParallaxEffect: false },
206
+ });
207
+ expect(wrapper.classes()).toContain("hover-pauses");
208
+ });
209
+
210
+ // ─── Reduced motion ──────────────────────────────────────────────────────
211
+
212
+ it("auto-pauses and applies reduced-motion class when the user prefers reduced motion", async () => {
213
+ stubMatchMedia(true);
214
+ const wrapper = await mountSuspended(RotatingCarouselImage, {
215
+ props: { useParallaxEffect: false },
216
+ });
217
+ expect(wrapper.classes()).toContain("paused");
218
+ expect(wrapper.classes()).toContain("reduced-motion");
219
+ });
220
+
221
+ it("does not check matchMedia when respectReducedMotion is false", async () => {
222
+ stubMatchMedia(false);
223
+ const matchMediaSpy = vi.fn(() => ({ matches: false, addEventListener: vi.fn() }));
224
+ vi.stubGlobal("matchMedia", matchMediaSpy);
225
+ await mountSuspended(RotatingCarouselImage, {
226
+ props: { respectReducedMotion: false, useParallaxEffect: false },
227
+ });
228
+ expect(matchMediaSpy).not.toHaveBeenCalled();
229
+ });
230
+ });