srcdev-nuxt-components 9.6.11 → 9.6.13
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.
- package/.claude/rules/consumer/ids-from-useid.md +2 -1
- package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +24 -1
- package/.claude/skills/component-naming.md +1 -0
- package/.claude/skills/components/banner-video.md +24 -30
- package/.claude/skills/components/content-docs.md +18 -5
- package/.claude/skills/components/display-dialog.md +16 -6
- package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
- package/.claude/skills/components/display-tooltip.md +17 -6
- package/.claude/skills/components/form-field.md +15 -0
- package/.claude/skills/components/form-fieldset.md +18 -2
- package/.claude/skills/components/marquee-scroller.md +51 -14
- package/.claude/skills/components/skip-links.md +16 -0
- package/.claude/skills/composable-tooltips-guide.md +4 -4
- package/.claude/skills/index.md +6 -6
- package/.claude/skills/testing-add-unit-test.md +8 -0
- package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
- package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
- package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
- package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
- package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
- package/.vscode/srcdev-component-form-field.code-snippets +7 -6
- package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
- package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
- package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
- package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
- package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
- package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
- package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
- package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
- package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
- package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
- package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
- package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
- package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
- package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
- package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
- package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
- package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
- package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
- package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
- package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
- package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
- package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
- package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
- package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
- package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
- package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
- package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
- package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
- package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
- package/app/composables/tests/useTooltips.spec.ts +3 -3
- package/app/types/components/index.ts +1 -1
- package/package.json +1 -1
- package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
- package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
- /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
3
|
import { nextTick } from "vue";
|
|
4
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
4
5
|
import MarqueeScroller from "../MarqueeScroller.vue";
|
|
5
6
|
|
|
6
7
|
const stubMatchMedia = (prefersReducedMotion: boolean) => {
|
|
7
|
-
vi.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
matches: prefersReducedMotion,
|
|
11
|
-
addEventListener: vi.fn(),
|
|
12
|
-
})
|
|
13
|
-
);
|
|
8
|
+
const query = { matches: prefersReducedMotion, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
|
9
|
+
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue(query));
|
|
10
|
+
return query;
|
|
14
11
|
};
|
|
15
12
|
|
|
16
13
|
// jsdom reports 0 for offsetWidth/scrollWidth — stub them per-element by class so
|
|
@@ -28,7 +25,7 @@ const stubDimensions = ({ containerWidth, groupScrollWidth }: { containerWidth:
|
|
|
28
25
|
Object.defineProperty(HTMLElement.prototype, "scrollWidth", {
|
|
29
26
|
configurable: true,
|
|
30
27
|
get() {
|
|
31
|
-
return this.classList.contains("marquee-group") ? groupScrollWidth : 0;
|
|
28
|
+
return this.classList.contains("marquee-scroller-group") ? groupScrollWidth : 0;
|
|
32
29
|
},
|
|
33
30
|
});
|
|
34
31
|
};
|
|
@@ -43,36 +40,44 @@ const marqueeData = [
|
|
|
43
40
|
{ id: 2, content: "logo-b" },
|
|
44
41
|
];
|
|
45
42
|
|
|
43
|
+
const isPaused = (wrapper: VueWrapper) => wrapper.attributes("data-paused") !== undefined;
|
|
44
|
+
const control = (wrapper: VueWrapper) => wrapper.find(".marquee-scroller-control");
|
|
45
|
+
|
|
46
46
|
describe("MarqueeScroller", () => {
|
|
47
|
+
let wrapper: VueWrapper | undefined;
|
|
48
|
+
|
|
47
49
|
beforeEach(() => {
|
|
48
50
|
stubMatchMedia(false);
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
afterEach(() => {
|
|
54
|
+
wrapper?.unmount();
|
|
55
|
+
wrapper = undefined;
|
|
52
56
|
restoreDimensions();
|
|
57
|
+
vi.restoreAllMocks();
|
|
53
58
|
});
|
|
54
59
|
|
|
55
60
|
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
56
61
|
|
|
57
62
|
it("mounts without error", async () => {
|
|
58
|
-
|
|
63
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
59
64
|
expect(wrapper.vm).toBeTruthy();
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
it("renders after mount (displayComponent flips true in onMounted)", async () => {
|
|
63
|
-
|
|
68
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
64
69
|
expect(wrapper.find(".marquee-scroller").exists()).toBe(true);
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
68
73
|
|
|
69
74
|
it("renders correct HTML structure (default props)", async () => {
|
|
70
|
-
|
|
75
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
71
76
|
expect(wrapper.html()).toMatchSnapshot();
|
|
72
77
|
});
|
|
73
78
|
|
|
74
79
|
it("renders correct HTML structure (all props set)", async () => {
|
|
75
|
-
|
|
80
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
76
81
|
props: {
|
|
77
82
|
animationRuntime: "20s",
|
|
78
83
|
reverse: true,
|
|
@@ -89,7 +94,7 @@ describe("MarqueeScroller", () => {
|
|
|
89
94
|
// ─── marqueeData / slots ─────────────────────────────────────────────────
|
|
90
95
|
|
|
91
96
|
it("renders each marqueeData item twice (main track + duplicate for looping)", async () => {
|
|
92
|
-
|
|
97
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
93
98
|
props: { marqueeData },
|
|
94
99
|
slots: {
|
|
95
100
|
"1": "<span class='logo-a'>A</span>",
|
|
@@ -101,116 +106,135 @@ describe("MarqueeScroller", () => {
|
|
|
101
106
|
});
|
|
102
107
|
|
|
103
108
|
it("renders no items when marqueeData is empty", async () => {
|
|
104
|
-
|
|
105
|
-
expect(wrapper.findAll(".item")).toHaveLength(0);
|
|
109
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
110
|
+
expect(wrapper.findAll(".marquee-scroller-item")).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("makes the duplicate group inert and aria-hidden so its copies can't be focused", async () => {
|
|
114
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { marqueeData } });
|
|
115
|
+
const groups = wrapper.findAll(".marquee-scroller-group");
|
|
116
|
+
expect(groups[0]!.attributes("inert")).toBeUndefined();
|
|
117
|
+
expect(groups[1]!.attributes("inert")).toBeDefined();
|
|
118
|
+
expect(groups[1]!.attributes("aria-hidden")).toBe("true");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("uses only component-prefixed structural class names", async () => {
|
|
122
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { marqueeData, showControls: true } });
|
|
123
|
+
for (const generic of [".item", ".control-btn", ".marquee-track", ".marquee-group"]) {
|
|
124
|
+
expect(wrapper.find(generic).exists()).toBe(false);
|
|
125
|
+
}
|
|
106
126
|
});
|
|
107
127
|
|
|
108
128
|
// ─── reverse ─────────────────────────────────────────────────────────────
|
|
109
129
|
|
|
110
|
-
it("does not
|
|
111
|
-
|
|
112
|
-
expect(wrapper.
|
|
130
|
+
it("does not set data-reverse by default", async () => {
|
|
131
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
132
|
+
expect(wrapper.attributes("data-reverse")).toBeUndefined();
|
|
113
133
|
});
|
|
114
134
|
|
|
115
|
-
it("
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
expect(wrapper.classes()).toContain("reverse");
|
|
135
|
+
it("sets data-reverse when reverse is true, with no bare state classes", async () => {
|
|
136
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { reverse: true } });
|
|
137
|
+
expect(wrapper.attributes("data-reverse")).toBe("");
|
|
138
|
+
expect(wrapper.classes()).not.toContain("reverse");
|
|
120
139
|
});
|
|
121
140
|
|
|
122
141
|
// ─── Accessibility attributes ────────────────────────────────────────────
|
|
123
142
|
|
|
124
143
|
it("defaults aria-label to 'Scrolling content'", async () => {
|
|
125
|
-
|
|
144
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
126
145
|
expect(wrapper.attributes("aria-label")).toBe("Scrolling content");
|
|
127
146
|
});
|
|
128
147
|
|
|
129
148
|
it("uses a custom ariaLabel when provided", async () => {
|
|
130
|
-
|
|
131
|
-
props: { ariaLabel: "Client logos" },
|
|
132
|
-
});
|
|
149
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { ariaLabel: "Client logos" } });
|
|
133
150
|
expect(wrapper.attributes("aria-label")).toBe("Client logos");
|
|
134
151
|
});
|
|
135
152
|
|
|
136
153
|
it("uses default screen-reader instructions when ariaDescription is not set", async () => {
|
|
137
|
-
|
|
138
|
-
expect(wrapper.find(".
|
|
154
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
155
|
+
expect(wrapper.find(".marquee-scroller-description").text()).toContain("Use spacebar to pause or play");
|
|
139
156
|
});
|
|
140
157
|
|
|
141
158
|
it("uses a custom ariaDescription when provided", async () => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
145
|
-
expect(wrapper.find(".sr-only").text()).toBe("Custom instructions");
|
|
159
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { ariaDescription: "Custom instructions" } });
|
|
160
|
+
expect(wrapper.find(".marquee-scroller-description").text()).toBe("Custom instructions");
|
|
146
161
|
});
|
|
147
162
|
|
|
148
|
-
it("sets aria-live to off while playing", async () => {
|
|
149
|
-
|
|
163
|
+
it("sets aria-live to off and hides the moving track while playing", async () => {
|
|
164
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
150
165
|
expect(wrapper.attributes("aria-live")).toBe("off");
|
|
166
|
+
expect(wrapper.find(".marquee-scroller-track").attributes("aria-hidden")).toBe("true");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("exposes the track (no aria-hidden attribute) once paused", async () => {
|
|
170
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
171
|
+
await wrapper.trigger("keydown", { key: " " });
|
|
172
|
+
expect(wrapper.find(".marquee-scroller-track").attributes("aria-hidden")).toBeUndefined();
|
|
151
173
|
});
|
|
152
174
|
|
|
153
175
|
// ─── Controls ────────────────────────────────────────────────────────────
|
|
154
176
|
|
|
155
177
|
it("does not render the control button by default", async () => {
|
|
156
|
-
|
|
157
|
-
expect(wrapper
|
|
178
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
179
|
+
expect(control(wrapper).exists()).toBe(false);
|
|
158
180
|
});
|
|
159
181
|
|
|
160
|
-
it("renders the control
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
expect(wrapper.
|
|
182
|
+
it("renders the control as an InputButton when showControls is true", async () => {
|
|
183
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
184
|
+
expect(control(wrapper).exists()).toBe(true);
|
|
185
|
+
expect(control(wrapper).classes()).toContain("input-button");
|
|
186
|
+
expect(control(wrapper).attributes("type")).toBe("button");
|
|
165
187
|
});
|
|
166
188
|
|
|
167
189
|
it("toggles paused state and aria-live when the control button is clicked", async () => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
await wrapper.find(".control-btn").trigger("click");
|
|
172
|
-
expect(wrapper.classes()).toContain("paused");
|
|
190
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
191
|
+
await control(wrapper).trigger("click");
|
|
192
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
173
193
|
expect(wrapper.attributes("aria-live")).toBe("polite");
|
|
174
|
-
expect(wrapper.find(".
|
|
194
|
+
expect(control(wrapper).find(".input-button-text").text()).toBe("Play animation");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not auto-pause when focus lands on the control button, so one click pauses", async () => {
|
|
198
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
199
|
+
await control(wrapper).trigger("focusin");
|
|
200
|
+
expect(isPaused(wrapper)).toBe(false);
|
|
201
|
+
await control(wrapper).trigger("click");
|
|
202
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
175
203
|
});
|
|
176
204
|
|
|
177
205
|
// ─── Control button icon / label customisation ──────────────────────────
|
|
178
206
|
|
|
179
|
-
it("renders the default
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
183
|
-
expect(wrapper.find(".control-btn").html()).toContain("mdi:pause");
|
|
207
|
+
it("renders the default pause icon while playing", async () => {
|
|
208
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
209
|
+
expect(control(wrapper).html()).toContain("mdi:pause");
|
|
184
210
|
});
|
|
185
211
|
|
|
186
212
|
it("renders the default play icon once paused", async () => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
await wrapper.find(".control-btn").trigger("click");
|
|
191
|
-
expect(wrapper.find(".control-btn").html()).toContain("mdi:play");
|
|
213
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
214
|
+
await control(wrapper).trigger("click");
|
|
215
|
+
expect(control(wrapper).html()).toContain("mdi:play");
|
|
192
216
|
});
|
|
193
217
|
|
|
194
218
|
it("renders a custom playIcon/pauseIcon", async () => {
|
|
195
|
-
|
|
219
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
196
220
|
props: { showControls: true, playIcon: "mdi:play-circle", pauseIcon: "mdi:pause-circle" },
|
|
197
221
|
});
|
|
198
|
-
expect(wrapper
|
|
199
|
-
await wrapper
|
|
200
|
-
expect(wrapper
|
|
222
|
+
expect(control(wrapper).html()).toContain("mdi:pause-circle");
|
|
223
|
+
await control(wrapper).trigger("click");
|
|
224
|
+
expect(control(wrapper).html()).toContain("mdi:play-circle");
|
|
201
225
|
});
|
|
202
226
|
|
|
203
|
-
it("uses custom playLabel/pauseLabel
|
|
204
|
-
|
|
227
|
+
it("uses custom playLabel/pauseLabel as the control button's accessible text", async () => {
|
|
228
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
205
229
|
props: { showControls: true, playLabel: "Reproduire", pauseLabel: "Suspendre" },
|
|
206
230
|
});
|
|
207
|
-
expect(wrapper.find(".
|
|
208
|
-
await wrapper
|
|
209
|
-
expect(wrapper.find(".
|
|
231
|
+
expect(control(wrapper).find(".input-button-text").text()).toBe("Suspendre");
|
|
232
|
+
await control(wrapper).trigger("click");
|
|
233
|
+
expect(control(wrapper).find(".input-button-text").text()).toBe("Reproduire");
|
|
210
234
|
});
|
|
211
235
|
|
|
212
236
|
it("replaces the toggle icon via the toggle-icon slot", async () => {
|
|
213
|
-
|
|
237
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
214
238
|
props: { showControls: true },
|
|
215
239
|
slots: { "toggle-icon": "<span class='custom-icon'>custom</span>" },
|
|
216
240
|
});
|
|
@@ -218,79 +242,132 @@ describe("MarqueeScroller", () => {
|
|
|
218
242
|
});
|
|
219
243
|
|
|
220
244
|
it("passes isPaused to the toggle-icon slot", async () => {
|
|
221
|
-
|
|
245
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
222
246
|
props: { showControls: true },
|
|
223
247
|
slots: { "toggle-icon": "<template #toggle-icon=\"{ isPaused }\"><span class='state'>{{ isPaused }}</span></template>" },
|
|
224
248
|
});
|
|
225
249
|
expect(wrapper.find(".state").text()).toBe("false");
|
|
226
|
-
await wrapper
|
|
250
|
+
await control(wrapper).trigger("click");
|
|
227
251
|
expect(wrapper.find(".state").text()).toBe("true");
|
|
228
252
|
});
|
|
229
253
|
|
|
230
254
|
// ─── Keyboard interaction ────────────────────────────────────────────────
|
|
231
255
|
|
|
232
|
-
it("toggles pause on spacebar keydown", async () => {
|
|
233
|
-
|
|
256
|
+
it("toggles pause on spacebar keydown on the region", async () => {
|
|
257
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
234
258
|
await wrapper.trigger("keydown", { key: " " });
|
|
235
|
-
expect(wrapper
|
|
259
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
236
260
|
await wrapper.trigger("keydown", { key: " " });
|
|
237
|
-
expect(wrapper
|
|
261
|
+
expect(isPaused(wrapper)).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("ignores spacebar bubbling from inside the region (the control button handles its own)", async () => {
|
|
265
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
266
|
+
await control(wrapper).trigger("keydown", { key: " " });
|
|
267
|
+
expect(isPaused(wrapper)).toBe(false);
|
|
238
268
|
});
|
|
239
269
|
|
|
240
270
|
it("does not react to arrow keys (no manual-stepping behaviour is implemented)", async () => {
|
|
241
|
-
|
|
271
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
242
272
|
await wrapper.trigger("keydown", { key: "ArrowLeft" });
|
|
243
273
|
await wrapper.trigger("keydown", { key: "ArrowRight" });
|
|
244
|
-
expect(wrapper
|
|
274
|
+
expect(isPaused(wrapper)).toBe(false);
|
|
245
275
|
});
|
|
246
276
|
|
|
247
|
-
// ─── Focus
|
|
277
|
+
// ─── Focus ───────────────────────────────────────────────────────────────
|
|
248
278
|
|
|
249
|
-
it("pauses
|
|
250
|
-
|
|
251
|
-
await wrapper.trigger("
|
|
252
|
-
expect(wrapper
|
|
279
|
+
it("pauses when focus enters the region", async () => {
|
|
280
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
281
|
+
await wrapper.trigger("focusin");
|
|
282
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
253
283
|
});
|
|
254
284
|
|
|
255
|
-
it("
|
|
256
|
-
|
|
257
|
-
await wrapper.trigger("
|
|
258
|
-
|
|
259
|
-
expect(wrapper.classes()).not.toContain("paused");
|
|
285
|
+
it("pauses on focus even when respectReducedMotion is false", async () => {
|
|
286
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { respectReducedMotion: false } });
|
|
287
|
+
await wrapper.trigger("focusin");
|
|
288
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
260
289
|
});
|
|
261
290
|
|
|
262
|
-
it("
|
|
263
|
-
|
|
264
|
-
props: {
|
|
291
|
+
it("pauses and exposes the track when focus lands on content inside an item", async () => {
|
|
292
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
293
|
+
props: { marqueeData },
|
|
294
|
+
slots: { "1": "<a class='logo-link' href='#a'>A</a>", "2": "<span>B</span>" },
|
|
265
295
|
});
|
|
266
|
-
await wrapper.trigger("
|
|
267
|
-
expect(wrapper
|
|
296
|
+
await wrapper.find(".logo-link").trigger("focusin");
|
|
297
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
298
|
+
expect(wrapper.find(".marquee-scroller-track").attributes("aria-hidden")).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("resumes when focus leaves the region if focus caused the pause", async () => {
|
|
302
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
303
|
+
await wrapper.trigger("focusin");
|
|
304
|
+
await wrapper.trigger("focusout", { relatedTarget: null });
|
|
305
|
+
expect(isPaused(wrapper)).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("stays paused while focus moves between elements inside the region", async () => {
|
|
309
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
310
|
+
await wrapper.trigger("focusin");
|
|
311
|
+
await wrapper.trigger("focusout", { relatedTarget: control(wrapper).element });
|
|
312
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("keeps a pause the user chose after focus leaves", async () => {
|
|
316
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { showControls: true } });
|
|
317
|
+
await control(wrapper).trigger("click");
|
|
318
|
+
await control(wrapper).trigger("focusout", { relatedTarget: null });
|
|
319
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
268
320
|
});
|
|
269
321
|
|
|
270
322
|
// ─── Reduced motion ──────────────────────────────────────────────────────
|
|
271
323
|
|
|
272
|
-
it("auto-pauses and
|
|
324
|
+
it("auto-pauses and sets data-reduced-motion when the user prefers reduced motion", async () => {
|
|
273
325
|
stubMatchMedia(true);
|
|
274
|
-
|
|
275
|
-
expect(wrapper
|
|
276
|
-
expect(wrapper.
|
|
326
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
327
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
328
|
+
expect(wrapper.attributes("data-reduced-motion")).toBe("");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("stays paused after focus leaves when the user prefers reduced motion", async () => {
|
|
332
|
+
stubMatchMedia(true);
|
|
333
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
334
|
+
await wrapper.trigger("focusin");
|
|
335
|
+
await wrapper.trigger("focusout", { relatedTarget: null });
|
|
336
|
+
expect(isPaused(wrapper)).toBe(true);
|
|
277
337
|
});
|
|
278
338
|
|
|
279
339
|
it("does not check matchMedia when respectReducedMotion is false", async () => {
|
|
280
|
-
|
|
281
|
-
const matchMediaSpy = vi.fn(() => ({ matches: false, addEventListener: vi.fn() }));
|
|
340
|
+
const matchMediaSpy = vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
|
|
282
341
|
vi.stubGlobal("matchMedia", matchMediaSpy);
|
|
283
|
-
await mountSuspended(MarqueeScroller, {
|
|
284
|
-
props: { respectReducedMotion: false },
|
|
285
|
-
});
|
|
342
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { respectReducedMotion: false } });
|
|
286
343
|
expect(matchMediaSpy).not.toHaveBeenCalled();
|
|
287
344
|
});
|
|
288
345
|
|
|
346
|
+
it("removes its reduced-motion listener on unmount", async () => {
|
|
347
|
+
const query = stubMatchMedia(false);
|
|
348
|
+
wrapper = await mountSuspended(MarqueeScroller);
|
|
349
|
+
const handler = query.addEventListener.mock.calls[0]![1];
|
|
350
|
+
wrapper.unmount();
|
|
351
|
+
wrapper = undefined;
|
|
352
|
+
expect(query.removeEventListener).toHaveBeenCalledWith("change", handler);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
it("applies styleClassPassthrough to the root and updates on change", async () => {
|
|
358
|
+
wrapper = await mountSuspended(MarqueeScroller, { props: { styleClassPassthrough: ["one"] } });
|
|
359
|
+
expect(wrapper.classes()).toContain("one");
|
|
360
|
+
await wrapper.setProps({ styleClassPassthrough: "two three" });
|
|
361
|
+
await nextTick();
|
|
362
|
+
expect(wrapper.classes()).toEqual(expect.arrayContaining(["marquee-scroller", "two", "three"]));
|
|
363
|
+
expect(wrapper.classes()).not.toContain("one");
|
|
364
|
+
});
|
|
365
|
+
|
|
289
366
|
// ─── Repeating marqueeData to fill a wide container ─────────────────────
|
|
290
367
|
|
|
291
368
|
it("repeats marqueeData enough times so one group's width covers a wide container", async () => {
|
|
292
369
|
stubDimensions({ containerWidth: 500, groupScrollWidth: 100 });
|
|
293
|
-
|
|
370
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
294
371
|
props: { marqueeData },
|
|
295
372
|
slots: { "1": "<span class='logo-a'></span>", "2": "<span class='logo-b'></span>" },
|
|
296
373
|
});
|
|
@@ -303,7 +380,7 @@ describe("MarqueeScroller", () => {
|
|
|
303
380
|
|
|
304
381
|
it("does not add extra copies when a single copy already covers the container", async () => {
|
|
305
382
|
stubDimensions({ containerWidth: 50, groupScrollWidth: 200 });
|
|
306
|
-
|
|
383
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
307
384
|
props: { marqueeData },
|
|
308
385
|
slots: { "1": "<span class='logo-a'></span>", "2": "<span class='logo-b'></span>" },
|
|
309
386
|
});
|
|
@@ -312,4 +389,15 @@ describe("MarqueeScroller", () => {
|
|
|
312
389
|
expect(wrapper.findAll(".logo-a")).toHaveLength(2);
|
|
313
390
|
expect(wrapper.findAll(".logo-b")).toHaveLength(2);
|
|
314
391
|
});
|
|
392
|
+
|
|
393
|
+
it("caps the repeat count at 50 copies for tiny items in a huge container", async () => {
|
|
394
|
+
stubDimensions({ containerWidth: 100000, groupScrollWidth: 2 });
|
|
395
|
+
wrapper = await mountSuspended(MarqueeScroller, {
|
|
396
|
+
props: { marqueeData },
|
|
397
|
+
slots: { "1": "<span class='logo-a'></span>", "2": "<span class='logo-b'></span>" },
|
|
398
|
+
});
|
|
399
|
+
await nextTick();
|
|
400
|
+
await nextTick();
|
|
401
|
+
expect(wrapper.findAll(".logo-a")).toHaveLength(100);
|
|
402
|
+
});
|
|
315
403
|
});
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
3
|
exports[`MarqueeScroller > renders correct HTML structure (all props set) 1`] = `
|
|
4
|
-
"<div class="marquee-scroller reverse" role="region" aria-label="Client logos" aria-live="off" tabindex="0">
|
|
5
|
-
<div class="
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
"<div class="marquee-scroller" data-reverse="" role="region" aria-label="Client logos" aria-live="off" tabindex="0">
|
|
5
|
+
<div class="marquee-scroller-description">Custom instructions</div><button type="button" data-testid="input-button" data-theme="default" data-variant="inline" data-icon-only="" class="input-button marquee-scroller-control">
|
|
6
|
+
<!--v-if-->
|
|
7
|
+
<!--v-if--><span class="input-button-text sr-only">Pause animation</span>
|
|
8
|
+
<!--v-if--><span class="input-button-icon" data-slot="icon-only"><span class="iconify i-mdi:pause" aria-hidden="true"></span></span>
|
|
9
|
+
</button>
|
|
10
|
+
<div class="marquee-scroller-track" aria-hidden="true">
|
|
11
|
+
<div class="marquee-scroller-group">
|
|
12
|
+
<div class="marquee-scroller-item"></div>
|
|
13
|
+
<div class="marquee-scroller-item"></div>
|
|
10
14
|
</div>
|
|
11
|
-
<div class="marquee-group" aria-hidden="true">
|
|
12
|
-
<div class="item"></div>
|
|
13
|
-
<div class="item"></div>
|
|
15
|
+
<div class="marquee-scroller-group" aria-hidden="true" inert="">
|
|
16
|
+
<div class="marquee-scroller-item"></div>
|
|
17
|
+
<div class="marquee-scroller-item"></div>
|
|
14
18
|
</div>
|
|
15
19
|
</div>
|
|
16
20
|
</div>"
|
|
@@ -18,11 +22,11 @@ exports[`MarqueeScroller > renders correct HTML structure (all props set) 1`] =
|
|
|
18
22
|
|
|
19
23
|
exports[`MarqueeScroller > renders correct HTML structure (default props) 1`] = `
|
|
20
24
|
"<div class="marquee-scroller" role="region" aria-label="Scrolling content" aria-live="off" tabindex="0">
|
|
21
|
-
<div class="
|
|
25
|
+
<div class="marquee-scroller-description">Use spacebar to pause or play the animation.</div>
|
|
22
26
|
<!--v-if-->
|
|
23
|
-
<div class="marquee-track" aria-hidden="true">
|
|
24
|
-
<div class="marquee-group"></div>
|
|
25
|
-
<div class="marquee-group" aria-hidden="true"></div>
|
|
27
|
+
<div class="marquee-scroller-track" aria-hidden="true">
|
|
28
|
+
<div class="marquee-scroller-group"></div>
|
|
29
|
+
<div class="marquee-scroller-group" aria-hidden="true" inert=""></div>
|
|
26
30
|
</div>
|
|
27
31
|
</div>"
|
|
28
32
|
`;
|