srcdev-nuxt-components 9.5.3 → 9.5.4

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 (60) hide show
  1. package/.claude/skills/colour-scheme-disable.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +214 -0
  3. package/.claude/skills/component-inline-action-button.md +2 -2
  4. package/.claude/skills/components/carousel-flip.md +3 -3
  5. package/.claude/skills/components/clipped-panel.md +2 -2
  6. package/.claude/skills/components/dashboard-quad-grid.md +2 -2
  7. package/.claude/skills/components/dashboard-stats-grid.md +1 -1
  8. package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
  9. package/.claude/skills/components/deep-expanding-menu.md +1 -1
  10. package/.claude/skills/components/google-reviews.md +193 -0
  11. package/.claude/skills/components/input-number.md +4 -0
  12. package/.claude/skills/components/input-otp.md +120 -0
  13. package/.claude/skills/components/section-parallax.md +1 -1
  14. package/.claude/skills/components/select-menu.md +9 -5
  15. package/.claude/skills/composable-colour-scheme.md +2 -0
  16. package/.claude/skills/index.md +5 -1
  17. package/.claude/skills/new-app-scaffold.md +1 -1
  18. package/.claude/skills/theming-colour-ramps.md +6 -33
  19. package/.claude/skills/theming-component-token-pattern.md +30 -0
  20. package/.claude/skills/theming-dark-mode.md +243 -0
  21. package/.claude/skills/theming-override-default.md +6 -7
  22. package/.claude/skills/theming-partial-override.md +11 -11
  23. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  24. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  25. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  26. package/README.md +81 -41
  27. package/app/assets/styles/setup/01.config/_head.css +0 -9
  28. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  29. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  30. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  31. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  32. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  33. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +10 -0
  34. package/app/components/02.molecules/select-menu/SelectMenu.vue +15 -1
  35. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  36. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  37. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +3 -3
  38. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  39. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  40. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  41. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  42. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  43. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  44. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  45. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  46. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  47. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  48. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  49. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  50. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  51. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  52. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  53. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  54. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  55. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  56. package/app/composables/useGoogleReviews.ts +4 -0
  57. package/app/types/components/google-reviews.d.ts +18 -0
  58. package/app/types/components/index.ts +1 -0
  59. package/nuxt.config.ts +9 -9
  60. package/package.json +9 -8
@@ -0,0 +1,323 @@
1
+ import { computed, ref } from "vue";
2
+ import GoogleReviews from "../GoogleReviews.vue";
3
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
5
+ import type { GoogleReview, GoogleReviewsData, MediaCanvas } from "~/types/components";
6
+
7
+ type LineClamp = "none" | "1" | "2" | "3" | "4" | "5" | "6" | "8";
8
+
9
+ type StoryArgs = InstanceType<typeof GoogleReviews>["$props"] & {
10
+ authorLineClamp: LineClamp;
11
+ textLineClamp: LineClamp;
12
+ };
13
+
14
+ const lineClampOptions: LineClamp[] = ["none", "1", "2", "3", "4", "5", "6", "8"];
15
+
16
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
17
+
18
+ const review = (n: number, rating: number, text: string, photo = true): GoogleReview => ({
19
+ authorName: ["Priya Shah", "Tom Ellis", "Grace Okafor", "Liam Byrne", "Mei Chen"][n - 1] ?? `Reviewer ${n}`,
20
+ authorUri: `https://www.google.com/maps/contrib/${n}`,
21
+ authorPhotoUri: photo ? `https://picsum.photos/seed/reviewer-${n}/96/96` : undefined,
22
+ rating,
23
+ text,
24
+ relativeTime: `${n} weeks ago`,
25
+ publishTime: "2026-09-18T10:00:00Z",
26
+ reviewUri: `https://www.google.com/maps/review/${n}`,
27
+ });
28
+
29
+ const fiveReviews: GoogleReview[] = [
30
+ review(1, 5, "Friendly, professional and really listened to what I wanted. Couldn't be happier with the result."),
31
+ review(2, 5, "Booked last minute and they still fitted me in. Great atmosphere and a brilliant finish."),
32
+ review(3, 4, "Lovely salon and a great cut. Parking nearby is a bit tricky, but worth it."),
33
+ review(4, 5, "Third visit now and every one has been spot on. Highly recommend."),
34
+ review(5, 3, "Good service overall, though my appointment started twenty minutes late."),
35
+ ];
36
+
37
+ const baseData: GoogleReviewsData = {
38
+ placeName: "Example Salon",
39
+ rating: 4.6,
40
+ totalReviews: 214,
41
+ mapsUri: "https://www.google.com/maps?cid=1",
42
+ reviews: fiveReviews,
43
+ };
44
+
45
+ const longText =
46
+ "I've been coming here for a couple of years now and wanted to finally write something.\n\nFrom the moment you walk in it's clear they care: the consultation is thorough, nobody rushes you, and they explain exactly what they're doing and why. My hair has never been in better condition, and the aftercare advice has made a real difference between visits. The team are warm and welcoming, the space is calm, and it genuinely feels like a treat rather than a chore. Can't recommend them highly enough.";
47
+
48
+ // ─── Meta ─────────────────────────────────────────────────────────────────────
49
+
50
+ const meta: Meta<StoryArgs> = {
51
+ title: "Organisms/Google Reviews",
52
+ component: GoogleReviews,
53
+ argTypes: {
54
+ authorLineClamp: {
55
+ control: "select",
56
+ options: lineClampOptions,
57
+ description: "Story control for --google-reviews-author-line-clamp (1 = single-line ellipsis)",
58
+ table: { category: "CSS tokens" },
59
+ },
60
+ textLineClamp: {
61
+ control: "select",
62
+ options: lineClampOptions,
63
+ description: "Story control for --google-reviews-text-line-clamp",
64
+ table: { category: "CSS tokens" },
65
+ },
66
+ data: { control: "object", description: "GoogleReviewsData, usually from useGoogleReviews()" },
67
+ minRating: { control: { type: "range", min: 0, max: 5, step: 1 }, description: "Hide reviews below this rating; shows the filter notice" },
68
+ showSummary: { control: "boolean", description: "Overall rating, stars and total count" },
69
+ tag: { control: "select", options: ["section", "div"] },
70
+ ariaLabel: { control: "text" },
71
+ prevLabel: { control: "text" },
72
+ nextLabel: { control: "text" },
73
+ ratingLabel: { control: "text", description: "{rating} is replaced" },
74
+ totalLabel: { control: "text", description: "{count} is replaced" },
75
+ readMoreLabel: { control: "text" },
76
+ filterNotice: { control: "text", description: "{rating} is replaced" },
77
+ attributionText: { control: "text", description: "Must keep the words \"Google Maps\"" },
78
+ },
79
+ args: {
80
+ data: baseData,
81
+ minRating: 0,
82
+ showSummary: true,
83
+ tag: "section",
84
+ authorLineClamp: "2",
85
+ textLineClamp: "5",
86
+ },
87
+ decorators: [
88
+ (story, context) => ({
89
+ components: { story, CanvasSwitcher },
90
+ setup() {
91
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
92
+ return { canvasName };
93
+ },
94
+ template: `
95
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
96
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
97
+ </div>
98
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
99
+ <story />
100
+ </div>
101
+ `,
102
+ }),
103
+ ],
104
+ parameters: {
105
+ layout: "fullscreen",
106
+ docs: {
107
+ description: {
108
+ component:
109
+ "Google reviews for one place, shown as a single horizontally scrolling row with prev/next buttons. Data comes from the layer's `/api/google-reviews` route via `useGoogleReviews()`; these stories use mock data.",
110
+ },
111
+ },
112
+ },
113
+ };
114
+
115
+ export default meta;
116
+ type Story = StoryObj<StoryArgs>;
117
+
118
+ function useStorySetup(args: StoryArgs) {
119
+ const componentArgs = computed(() => {
120
+ const { authorLineClamp: _authorLineClamp, textLineClamp: _textLineClamp, ...rest } = args;
121
+ return rest;
122
+ });
123
+ const tokenStyles = computed(() => ({
124
+ "--google-reviews-author-line-clamp": args.authorLineClamp,
125
+ "--google-reviews-text-line-clamp": args.textLineClamp,
126
+ }));
127
+ return { args, componentArgs, tokenStyles };
128
+ }
129
+
130
+ const render = (args: StoryArgs) => ({
131
+ components: { GoogleReviews },
132
+ setup() {
133
+ return useStorySetup(args);
134
+ },
135
+ template: `<div :style="tokenStyles"><GoogleReviews v-bind="componentArgs" /></div>`,
136
+ });
137
+
138
+ // ─── Stories ──────────────────────────────────────────────────────────────────
139
+
140
+ export const Default: Story = {
141
+ render,
142
+ parameters: {
143
+ docs: { description: { story: "Five reviews (Google's maximum). Use the canvas switcher above to try narrower widths." } },
144
+ },
145
+ };
146
+
147
+ export const FewReviews: Story = {
148
+ name: "Few Reviews (All Fit)",
149
+ args: { data: { ...baseData, reviews: fiveReviews.slice(0, 2) } },
150
+ render,
151
+ parameters: {
152
+ docs: { description: { story: "When every card fits, the prev/next buttons are hidden." } },
153
+ },
154
+ };
155
+
156
+ export const NarrowContainer: Story = {
157
+ render,
158
+ parameters: {
159
+ initialCanvas: "mobileCanvas",
160
+ docs: {
161
+ description: {
162
+ story:
163
+ "Phone-width container: the row stays a single row and scrolls with snapping. Check keyboard scrolling (focus the row, use the arrow keys) and that the buttons disable at each end.",
164
+ },
165
+ },
166
+ },
167
+ };
168
+
169
+ export const LongText: Story = {
170
+ args: { data: { ...baseData, reviews: [review(1, 5, longText), ...fiveReviews.slice(1)] } },
171
+ render,
172
+ parameters: {
173
+ docs: { description: { story: "Long reviews are clamped (`--google-reviews-text-line-clamp`, default 5 lines) with a link to read the rest on Google." } },
174
+ },
175
+ };
176
+
177
+ export const NoAvatars: Story = {
178
+ args: { data: { ...baseData, reviews: fiveReviews.map((r) => ({ ...r, authorPhotoUri: undefined })) } },
179
+ render,
180
+ parameters: {
181
+ docs: { description: { story: "Reviewers without a photo show their initials." } },
182
+ },
183
+ };
184
+
185
+ export const Filtered: Story = {
186
+ args: { minRating: 4 },
187
+ render,
188
+ parameters: {
189
+ docs: { description: { story: "`minRating` hides lower-rated reviews and shows the filter notice Google's terms require." } },
190
+ },
191
+ };
192
+
193
+ export const WithHeading: Story = {
194
+ render: (args: StoryArgs) => ({
195
+ components: { GoogleReviews },
196
+ setup() {
197
+ return useStorySetup(args);
198
+ },
199
+ template: `
200
+ <div :style="tokenStyles">
201
+ <GoogleReviews v-bind="componentArgs">
202
+ <template #heading="{ headingId }">
203
+ <h2 :id="headingId" style="margin:0">What clients say</h2>
204
+ </template>
205
+ </GoogleReviews>
206
+ </div>
207
+ `,
208
+ }),
209
+ parameters: {
210
+ docs: { description: { story: "The `heading` slot receives `headingId`; binding it labels the section by the heading instead of `ariaLabel`." } },
211
+ },
212
+ };
213
+
214
+ export const Themed: Story = {
215
+ args: { textLineClamp: "3" },
216
+ render: (args: StoryArgs) => ({
217
+ components: { GoogleReviews },
218
+ setup() {
219
+ return useStorySetup(args);
220
+ },
221
+ template: `
222
+ <div
223
+ :style="[tokenStyles, {
224
+ '--google-reviews-card-width': 'min(26rem, 80%)',
225
+ '--google-reviews-card-surface': 'var(--amber-01)',
226
+ '--google-reviews-card-text-colour': 'var(--amber-10)',
227
+ '--google-reviews-card-border-colour': 'var(--amber-03)',
228
+ '--google-reviews-card-border-radius': '2rem',
229
+ '--google-reviews-star-colour': 'var(--amber-07)',
230
+ }]"
231
+ >
232
+ <GoogleReviews v-bind="componentArgs" />
233
+ </div>
234
+ `,
235
+ }),
236
+ parameters: {
237
+ docs: { description: { story: "Card width, colours, radius, star colour and line clamp set through public tokens on a wrapper." } },
238
+ },
239
+ };
240
+
241
+ // ─── Stress test ──────────────────────────────────────────────────────────────
242
+
243
+ const stressReviews: GoogleReview[] = [
244
+ {
245
+ authorName: "Bartholomew Maximilian Montgomery-Fitzgerald-Worthington-Smythe the Third of Little Snoring",
246
+ authorUri: "https://www.google.com/maps/contrib/1",
247
+ authorPhotoUri: "https://picsum.photos/seed/reviewer-1/96/96",
248
+ rating: 5,
249
+ text: `${"Absolutely wonderful. ".repeat(60)}\n\n\n\nStill going after four blank lines.`,
250
+ relativeTime: "11 years, 4 months and 3 days ago (edited)",
251
+ publishTime: "2015-06-01T10:00:00Z",
252
+ reviewUri: "https://www.google.com/maps/review/1",
253
+ },
254
+ {
255
+ authorName: "Pneumonoultramicroscopicsilicovolcanoconiosisfanaccountnospaces",
256
+ rating: 1,
257
+ text: `Sooooooo${"o".repeat(200)} good. See https://example.com/a/very/long/path/that/never/breaks/${"segment".repeat(20)}`,
258
+ relativeTime: "a week ago",
259
+ publishTime: "",
260
+ },
261
+ {
262
+ authorName: "🌸✨ Rosa 🌸",
263
+ authorPhotoUri: "https://example.invalid/expired-photo.jpg",
264
+ rating: 4.95,
265
+ text: "Emoji everywhere 💇‍♀️💅🏽🔥🔥🔥 and <script>alert('xss')</script> rendered as text, plus &amp; entities.",
266
+ relativeTime: "yesterday",
267
+ publishTime: "2026-10-02T10:00:00Z",
268
+ reviewUri: "https://www.google.com/maps/review/3",
269
+ },
270
+ {
271
+ authorName: "مريم الحسيني",
272
+ rating: 3,
273
+ text: "خدمة رائعة وفريق ودود جداً. سأعود بالتأكيد! Mixed with English mid-sentence.",
274
+ relativeTime: "منذ أسبوعين",
275
+ publishTime: "2026-09-18T10:00:00Z",
276
+ reviewUri: "https://www.google.com/maps/review/4",
277
+ },
278
+ {
279
+ authorName: "X",
280
+ rating: 0,
281
+ text: "",
282
+ relativeTime: "",
283
+ publishTime: "",
284
+ },
285
+ {
286
+ authorName: "",
287
+ rating: 7,
288
+ text: ".",
289
+ relativeTime: "just now",
290
+ publishTime: "2026-10-03T10:00:00Z",
291
+ reviewUri: "https://www.google.com/maps/review/6",
292
+ },
293
+ ];
294
+
295
+ export const StressTest: Story = {
296
+ name: "Stress Test (Worst-Case Data)",
297
+ args: {
298
+ data: {
299
+ placeName: "A Place Name That Goes On And On ".repeat(5),
300
+ rating: 4.95,
301
+ totalReviews: 123456789,
302
+ mapsUri: "https://www.google.com/maps?cid=1",
303
+ reviews: stressReviews,
304
+ },
305
+ ratingLabel: "Durchschnittliche Bewertung von {rating} von insgesamt fünf möglichen Sternen",
306
+ totalLabel: "{count} Rezensionen auf Google ansehen und weitere Bewertungen unserer Kundschaft lesen",
307
+ readMoreLabel: "Vollständige Rezension auf Google Maps weiterlesen",
308
+ attributionText: "Rezensionen bereitgestellt von Google Maps, Inhalte von Dritten, ohne Gewähr für Richtigkeit",
309
+ },
310
+ render,
311
+ parameters: {
312
+ docs: {
313
+ description: {
314
+ story:
315
+ "Deliberately hostile data to find breakage: very long and unbroken author names and text, " +
316
+ "blank lines, an unbroken URL (reviewer names are clamped to 2 lines, review text to 5), emoji (including in initials), HTML-like text (must render as text), " +
317
+ "right-to-left Arabic, empty name/text/date, a broken photo URL, out-of-range ratings (0, 4.95, 7), " +
318
+ "a nine-digit total and long German copy for every label. Check at every canvas width: nothing " +
319
+ "should overflow its card, cards stay one row, and the footer wraps cleanly.",
320
+ },
321
+ },
322
+ },
323
+ };
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import GoogleReviewCard from "../GoogleReviewCard.vue";
4
+ import type { GoogleReview } from "~/types/components";
5
+
6
+ const review: GoogleReview = {
7
+ authorName: "Alex Doe",
8
+ authorUri: "https://maps.google.com/contrib/1",
9
+ authorPhotoUri: "https://lh3.googleusercontent.com/a",
10
+ rating: 4,
11
+ text: "Lovely visit, would come again.",
12
+ relativeTime: "2 weeks ago",
13
+ publishTime: "2026-09-18T10:00:00Z",
14
+ reviewUri: "https://maps.google.com/review/1",
15
+ };
16
+
17
+ const mountCard = (props: Partial<InstanceType<typeof GoogleReviewCard>["$props"]> = {}) =>
18
+ mountSuspended(GoogleReviewCard, { props: { review, ...props } });
19
+
20
+ describe("GoogleReviewCard", () => {
21
+ it("mounts without error", async () => {
22
+ const wrapper = await mountCard();
23
+ expect(wrapper.vm).toBeTruthy();
24
+ });
25
+
26
+ it("renders correct HTML structure", async () => {
27
+ const wrapper = await mountCard();
28
+ expect(wrapper.html()).toMatchSnapshot();
29
+ });
30
+
31
+ it("renders as an article by default and a div when tag is div", async () => {
32
+ expect((await mountCard()).element.tagName).toBe("ARTICLE");
33
+ expect((await mountCard({ tag: "div" })).element.tagName).toBe("DIV");
34
+ });
35
+
36
+ it("links the author name to their profile", async () => {
37
+ const wrapper = await mountCard();
38
+ const author = wrapper.find(".google-review-card-author-name");
39
+ expect(author.element.tagName).toBe("A");
40
+ expect(author.attributes("href")).toBe(review.authorUri);
41
+ expect(author.text()).toBe("Alex Doe");
42
+ });
43
+
44
+ it("shows the author name as plain text when there is no profile link", async () => {
45
+ const wrapper = await mountCard({ review: { ...review, authorUri: undefined } });
46
+ expect(wrapper.find(".google-review-card-author-name").element.tagName).toBe("SPAN");
47
+ });
48
+
49
+ it("hides the avatar from screen readers, since the name is already read out", async () => {
50
+ const avatar = (await mountCard()).find(".display-avatar");
51
+ expect(avatar.attributes("aria-hidden")).toBe("true");
52
+ expect(avatar.find(".sr-only").exists()).toBe(false);
53
+ });
54
+
55
+ it("shows the author photo, or initials when there is no photo", async () => {
56
+ expect((await mountCard()).find(".display-avatar img").exists()).toBe(true);
57
+ const noPhoto = await mountCard({ review: { ...review, authorPhotoUri: undefined } });
58
+ expect(noPhoto.find(".display-avatar img").exists()).toBe(false);
59
+ expect(noPhoto.find(".display-avatar").text()).toContain("AD");
60
+ });
61
+
62
+ it("takes whole characters for initials when a name starts with an emoji", async () => {
63
+ const wrapper = await mountCard({ review: { ...review, authorName: "🌸 Rosa Lee", authorPhotoUri: undefined } });
64
+ expect(wrapper.find(".display-avatar").text()).toContain("🌸R");
65
+ });
66
+
67
+ it("hides the stars from screen readers and announces the rating as text", async () => {
68
+ const wrapper = await mountCard();
69
+ expect(wrapper.find(".google-review-card-stars").attributes("aria-hidden")).toBe("true");
70
+ expect(wrapper.find(".google-review-card-rating .sr-only").text()).toBe("Rated 4 out of 5");
71
+ });
72
+
73
+ it("shows the relative date with a machine-readable datetime", async () => {
74
+ const time = (await mountCard()).find("time");
75
+ expect(time.text()).toBe("2 weeks ago");
76
+ expect(time.attributes("datetime")).toBe(review.publishTime);
77
+ });
78
+
79
+ it("links to the full review on Google, described by the author name", async () => {
80
+ const wrapper = await mountCard();
81
+ const link = wrapper.find(".google-review-card-link");
82
+ expect(link.attributes("href")).toBe(review.reviewUri);
83
+ expect(link.text()).toBe("Read on Google");
84
+ const authorId = wrapper.find(".google-review-card-author-name").attributes("id");
85
+ expect(authorId).toBeTruthy();
86
+ expect(link.attributes("aria-describedby")).toBe(authorId);
87
+ });
88
+
89
+ it("omits the read link when there is no review link", async () => {
90
+ const wrapper = await mountCard({ review: { ...review, reviewUri: undefined } });
91
+ expect(wrapper.find(".google-review-card-link").exists()).toBe(false);
92
+ });
93
+
94
+ it("uses custom label text", async () => {
95
+ const wrapper = await mountCard({ ratingLabel: "{rating} étoiles sur 5", readMoreLabel: "Lire sur Google" });
96
+ expect(wrapper.find(".sr-only").text()).toBe("4 étoiles sur 5");
97
+ expect(wrapper.find(".google-review-card-link").text()).toBe("Lire sur Google");
98
+ });
99
+
100
+ it("applies styleClassPassthrough classes", async () => {
101
+ const wrapper = await mountCard({ styleClassPassthrough: ["custom-card"] });
102
+ expect(wrapper.classes()).toContain("custom-card");
103
+ });
104
+ });
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, h, nextTick, ref } from "vue";
4
+ import GoogleReviews from "../GoogleReviews.vue";
5
+ import type { GoogleReviewsData } from "~/types/components";
6
+
7
+ const makeReview = (n: number, rating: number) => ({
8
+ authorName: `Reviewer ${n}`,
9
+ authorUri: `https://maps.google.com/contrib/${n}`,
10
+ rating,
11
+ text: `Review text ${n}`,
12
+ relativeTime: `${n} weeks ago`,
13
+ publishTime: "2026-09-18T10:00:00Z",
14
+ reviewUri: `https://maps.google.com/review/${n}`,
15
+ });
16
+
17
+ const data: GoogleReviewsData = {
18
+ placeName: "Test Salon",
19
+ rating: 4.66,
20
+ totalReviews: 128,
21
+ mapsUri: "https://maps.google.com/?cid=1",
22
+ reviews: [makeReview(1, 5), makeReview(2, 4), makeReview(3, 3)],
23
+ };
24
+
25
+ const mountReviews = (props: Partial<InstanceType<typeof GoogleReviews>["$props"]> = {}, slots = {}) =>
26
+ mountSuspended(GoogleReviews, { props: { data, ...props }, slots });
27
+
28
+ const setScrollMetrics = (el: Element, metrics: { scrollWidth: number; clientWidth: number; scrollLeft: number }) => {
29
+ for (const [key, value] of Object.entries(metrics)) {
30
+ Object.defineProperty(el, key, { configurable: true, value });
31
+ }
32
+ };
33
+
34
+ beforeEach(() => {
35
+ vi.stubGlobal(
36
+ "ResizeObserver",
37
+ vi.fn(function () {
38
+ return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
39
+ })
40
+ );
41
+ });
42
+
43
+ describe("GoogleReviews", () => {
44
+ it("mounts without error", async () => {
45
+ const wrapper = await mountReviews();
46
+ expect(wrapper.vm).toBeTruthy();
47
+ });
48
+
49
+ it("renders correct HTML structure", async () => {
50
+ const wrapper = await mountReviews();
51
+ expect(wrapper.html()).toMatchSnapshot();
52
+ });
53
+
54
+ // ─── Empty states ───────────────────────────────────────────────────────
55
+
56
+ it("renders nothing when data is null", async () => {
57
+ const wrapper = await mountReviews({ data: null });
58
+ expect(wrapper.find(".google-reviews").exists()).toBe(false);
59
+ });
60
+
61
+ it("renders nothing when every review is filtered out", async () => {
62
+ const wrapper = await mountReviews({ minRating: 5, data: { ...data, reviews: [makeReview(1, 4)] } });
63
+ expect(wrapper.find(".google-reviews").exists()).toBe(false);
64
+ });
65
+
66
+ // ─── Summary and attribution ───────────────────────────────────────────
67
+
68
+ it("shows the overall rating, total count and a link to the Google Maps listing", async () => {
69
+ const wrapper = await mountReviews();
70
+ expect(wrapper.find(".google-reviews-summary-rating").text()).toBe("4.7");
71
+ expect(wrapper.find(".google-reviews-summary .sr-only").text()).toBe("Rated 4.7 out of 5");
72
+ const link = wrapper.find("a.google-reviews-summary-link");
73
+ expect(link.attributes("href")).toBe(data.mapsUri);
74
+ expect(link.text()).toBe("128 reviews on Google");
75
+ });
76
+
77
+ it("hides the summary when showSummary is false", async () => {
78
+ const wrapper = await mountReviews({ showSummary: false });
79
+ expect(wrapper.find(".google-reviews-summary").exists()).toBe(false);
80
+ });
81
+
82
+ it("always renders the Google Maps attribution", async () => {
83
+ const wrapper = await mountReviews({ showSummary: false });
84
+ expect(wrapper.find(".google-reviews-attribution").text()).toBe("Reviews from Google Maps");
85
+ });
86
+
87
+ // ─── Semantics ─────────────────────────────────────────────────────────
88
+
89
+ it("is a labelled section containing a list of reviews", async () => {
90
+ const wrapper = await mountReviews();
91
+ expect(wrapper.element.tagName).toBe("SECTION");
92
+ expect(wrapper.attributes("aria-label")).toBe("Google reviews");
93
+ expect(wrapper.find("ul.google-reviews-list").exists()).toBe(true);
94
+ expect(wrapper.findAll("li.google-reviews-item")).toHaveLength(3);
95
+ });
96
+
97
+ it("labels the section by the heading slot when one is given", async () => {
98
+ const wrapper = await mountReviews(
99
+ {},
100
+ { heading: ({ headingId }: { headingId: string }) => h("h2", { id: headingId }, "What clients say") }
101
+ );
102
+ const heading = wrapper.find("h2");
103
+ expect(wrapper.attributes("aria-labelledby")).toBe(heading.attributes("id"));
104
+ expect(wrapper.attributes("aria-label")).toBeUndefined();
105
+ });
106
+
107
+ it("switches between aria-label and aria-labelledby when the heading slot is toggled after mount", async () => {
108
+ const showHeading = ref(false);
109
+ const Host = defineComponent({
110
+ setup() {
111
+ return () =>
112
+ h(GoogleReviews, { data }, showHeading.value
113
+ ? { heading: ({ headingId }: { headingId: string }) => h("h2", { id: headingId }, "Reviews") }
114
+ : {});
115
+ },
116
+ });
117
+ const wrapper = await mountSuspended(Host);
118
+ const root = () => wrapper.find(".google-reviews");
119
+ expect(root().attributes("aria-label")).toBe("Google reviews");
120
+
121
+ showHeading.value = true;
122
+ await nextTick();
123
+ expect(root().attributes("aria-labelledby")).toBe(wrapper.find("h2").attributes("id"));
124
+ expect(root().attributes("aria-label")).toBeUndefined();
125
+
126
+ showHeading.value = false;
127
+ await nextTick();
128
+ expect(root().attributes("aria-labelledby")).toBeUndefined();
129
+ expect(root().attributes("aria-label")).toBe("Google reviews");
130
+ });
131
+
132
+ it("makes the scroll area keyboard focusable", async () => {
133
+ const wrapper = await mountReviews();
134
+ expect(wrapper.find(".google-reviews-list").attributes("tabindex")).toBe("0");
135
+ });
136
+
137
+ // ─── Filtering ─────────────────────────────────────────────────────────
138
+
139
+ it("hides lower-rated reviews and shows the filter notice when minRating is set", async () => {
140
+ const wrapper = await mountReviews({ minRating: 4 });
141
+ expect(wrapper.findAll(".google-reviews-item")).toHaveLength(2);
142
+ expect(wrapper.find(".google-reviews-filter-notice").text()).toBe(
143
+ "Showing reviews rated 4 stars and above, in Google's order."
144
+ );
145
+ });
146
+
147
+ it("shows no filter notice when there is no filter", async () => {
148
+ const wrapper = await mountReviews();
149
+ expect(wrapper.find(".google-reviews-filter-notice").exists()).toBe(false);
150
+ });
151
+
152
+ // ─── Prev/next controls ────────────────────────────────────────────────
153
+
154
+ it("hides the prev/next buttons when every card fits", async () => {
155
+ const wrapper = await mountReviews();
156
+ expect(wrapper.find(".google-reviews-controls").exists()).toBe(false);
157
+ });
158
+
159
+ it("shows the prev/next buttons when cards overflow, disabling each at its end", async () => {
160
+ const wrapper = await mountReviews();
161
+ const list = wrapper.find(".google-reviews-list");
162
+
163
+ setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 0 });
164
+ await list.trigger("scroll");
165
+ expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).toBe("true");
166
+ expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).toBe("false");
167
+
168
+ setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 600 });
169
+ await list.trigger("scroll");
170
+ expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).toBe("false");
171
+ expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).toBe("true");
172
+ });
173
+
174
+ it("scrolls by one card when prev/next is pressed", async () => {
175
+ const wrapper = await mountReviews();
176
+ const list = wrapper.find(".google-reviews-list");
177
+ const scrollBy = vi.fn();
178
+ (list.element as HTMLElement).scrollBy = scrollBy;
179
+ Object.defineProperty(wrapper.find(".google-reviews-item").element, "offsetWidth", { configurable: true, value: 300 });
180
+
181
+ setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 300 });
182
+ await list.trigger("scroll");
183
+
184
+ await wrapper.find(".google-reviews-next").trigger("click");
185
+ expect(scrollBy).toHaveBeenLastCalledWith({ left: 300 });
186
+ await wrapper.find(".google-reviews-prev").trigger("click");
187
+ expect(scrollBy).toHaveBeenLastCalledWith({ left: -300 });
188
+ });
189
+
190
+ it("gives the icon-only buttons accessible names", async () => {
191
+ const wrapper = await mountReviews();
192
+ const list = wrapper.find(".google-reviews-list");
193
+ setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 0 });
194
+ await list.trigger("scroll");
195
+ expect(wrapper.find(".google-reviews-prev .button-text").text()).toBe("Previous reviews");
196
+ expect(wrapper.find(".google-reviews-next .button-text").text()).toBe("Next reviews");
197
+ });
198
+
199
+ // ─── Copy and slots ────────────────────────────────────────────────────
200
+
201
+ it("uses custom label text throughout", async () => {
202
+ const wrapper = await mountReviews({
203
+ minRating: 4,
204
+ ariaLabel: "Avis Google",
205
+ ratingLabel: "{rating} sur 5",
206
+ totalLabel: "{count} avis sur Google",
207
+ readMoreLabel: "Lire sur Google",
208
+ filterNotice: "Avis de {rating} étoiles et plus.",
209
+ attributionText: "Avis de Google Maps",
210
+ });
211
+ expect(wrapper.attributes("aria-label")).toBe("Avis Google");
212
+ expect(wrapper.find(".google-reviews-summary .sr-only").text()).toBe("4.7 sur 5");
213
+ expect(wrapper.find(".google-reviews-summary-link").text()).toBe("128 avis sur Google");
214
+ expect(wrapper.find(".google-review-card-link").text()).toBe("Lire sur Google");
215
+ expect(wrapper.find(".google-reviews-filter-notice").text()).toBe("Avis de 4 étoiles et plus.");
216
+ expect(wrapper.find(".google-reviews-attribution").text()).toBe("Avis de Google Maps");
217
+ });
218
+
219
+ it("replaces each card with the card slot", async () => {
220
+ const wrapper = await mountReviews(
221
+ {},
222
+ { card: ({ review }: { review: { authorName: string } }) => h("div", { class: "custom-card" }, review.authorName) }
223
+ );
224
+ expect(wrapper.findAll(".custom-card")).toHaveLength(3);
225
+ expect(wrapper.find(".google-review-card").exists()).toBe(false);
226
+ });
227
+
228
+ it("applies styleClassPassthrough classes", async () => {
229
+ const wrapper = await mountReviews({ styleClassPassthrough: ["custom-reviews"] });
230
+ expect(wrapper.classes()).toContain("custom-reviews");
231
+ });
232
+ });
@@ -0,0 +1,9 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`GoogleReviewCard > renders correct HTML structure 1`] = `
4
+ "<article class="google-review-card">
5
+ <div class="google-review-card-author"><span class="display-avatar md" aria-hidden="true"><img data-nuxt-img="" srcset="/_ipx/_/https://lh3.googleusercontent.com/a 1x, /_ipx/_/https://lh3.googleusercontent.com/a 2x" alt="" class="display-avatar-image" src="/_ipx/_/https://lh3.googleusercontent.com/a"></span><a id="v-0-0" href="https://maps.google.com/contrib/1" class="google-review-card-author-name">Alex Doe</a><time datetime="2026-09-18T10:00:00Z" class="google-review-card-time">2 weeks ago</time></div>
6
+ <div class="google-review-card-rating"><span class="google-review-card-stars" style="--_rating: 4;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 4 out of 5</span></div>
7
+ <p class="google-review-card-text">Lovely visit, would come again.</p><a href="https://maps.google.com/review/1" aria-describedby="v-0-0" class="google-review-card-link">Read on Google</a>
8
+ </article>"
9
+ `;