srcdev-nuxt-components 9.5.3 → 9.5.5

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 (62) 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 +19 -5
  15. package/.claude/skills/components/triple-toggle-switch.md +7 -1
  16. package/.claude/skills/composable-colour-scheme.md +2 -0
  17. package/.claude/skills/index.md +5 -1
  18. package/.claude/skills/new-app-scaffold.md +1 -1
  19. package/.claude/skills/theming-colour-ramps.md +6 -33
  20. package/.claude/skills/theming-component-token-pattern.md +30 -0
  21. package/.claude/skills/theming-dark-mode.md +243 -0
  22. package/.claude/skills/theming-override-default.md +6 -7
  23. package/.claude/skills/theming-partial-override.md +11 -11
  24. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  25. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  26. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  27. package/README.md +81 -41
  28. package/app/assets/styles/setup/01.config/_head.css +0 -9
  29. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  30. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  31. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  32. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  33. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  34. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +19 -1
  35. package/app/components/02.molecules/select-menu/SelectMenu.vue +54 -3
  36. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  37. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  38. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +12 -12
  39. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  40. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  41. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  42. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  43. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  44. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  45. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  46. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  47. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  48. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  49. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  50. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  51. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  52. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  53. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  54. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  55. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  56. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  57. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +11 -3
  58. package/app/composables/useGoogleReviews.ts +4 -0
  59. package/app/types/components/google-reviews.d.ts +18 -0
  60. package/app/types/components/index.ts +1 -0
  61. package/nuxt.config.ts +9 -9
  62. package/package.json +9 -8
@@ -0,0 +1,275 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ v-if="filteredReviews.length"
5
+ class="google-reviews"
6
+ :class="elementClasses"
7
+ :aria-labelledby="hasHeading() ? headingId : undefined"
8
+ :aria-label="hasHeading() ? undefined : ariaLabel"
9
+ >
10
+ <div v-if="hasHeading() || showSummary" class="google-reviews-header">
11
+ <slot name="heading" :heading-id="headingId"></slot>
12
+ <div v-if="showSummary && data" class="google-reviews-summary">
13
+ <span class="google-reviews-summary-rating" aria-hidden="true">{{ data.rating.toFixed(1) }}</span>
14
+ <span class="google-reviews-stars" :style="{ '--_rating': data.rating }" aria-hidden="true">★★★★★</span>
15
+ <span class="sr-only">{{ formatLabel(ratingLabel, data.rating.toFixed(1)) }}</span>
16
+ <a v-if="data.mapsUri" :href="data.mapsUri" class="google-reviews-summary-link">
17
+ {{ formatLabel(totalLabel, data.totalReviews) }}
18
+ </a>
19
+ <span v-else class="google-reviews-summary-link">{{ formatLabel(totalLabel, data.totalReviews) }}</span>
20
+ </div>
21
+ </div>
22
+
23
+ <p v-if="minRating > 0" class="google-reviews-filter-notice">{{ formatLabel(filterNotice, minRating) }}</p>
24
+
25
+ <ul ref="listRef" class="google-reviews-list" tabindex="0" @scroll.passive="updateScrollState">
26
+ <li v-for="(review, index) in filteredReviews" :key="review.reviewUri ?? index" class="google-reviews-item">
27
+ <slot name="card" :review="review">
28
+ <GoogleReviewCard :review="review" :rating-label="ratingLabel" :read-more-label="readMoreLabel" />
29
+ </slot>
30
+ </li>
31
+ </ul>
32
+
33
+ <div class="google-reviews-footer">
34
+ <div v-if="isOverflowing" class="google-reviews-controls">
35
+ <InputButton
36
+ variant="secondary"
37
+ :button-text="prevLabel"
38
+ :readonly="atStart"
39
+ class="google-reviews-prev"
40
+ @click="scrollByCard(-1)"
41
+ >
42
+ <template #iconOnly>
43
+ <Icon :name="prevIcon" class="icon" />
44
+ </template>
45
+ </InputButton>
46
+ <InputButton
47
+ variant="secondary"
48
+ :button-text="nextLabel"
49
+ :readonly="atEnd"
50
+ class="google-reviews-next"
51
+ @click="scrollByCard(1)"
52
+ >
53
+ <template #iconOnly>
54
+ <Icon :name="nextIcon" class="icon" />
55
+ </template>
56
+ </InputButton>
57
+ </div>
58
+ <p class="google-reviews-attribution">{{ attributionText }}</p>
59
+ </div>
60
+ </component>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import type { GoogleReviewsData } from "~/types/components";
65
+
66
+ interface Props {
67
+ data: GoogleReviewsData | null;
68
+ minRating?: number;
69
+ showSummary?: boolean;
70
+ tag?: "section" | "div";
71
+ ariaLabel?: string;
72
+ prevLabel?: string;
73
+ nextLabel?: string;
74
+ prevIcon?: string;
75
+ nextIcon?: string;
76
+ ratingLabel?: string;
77
+ totalLabel?: string;
78
+ readMoreLabel?: string;
79
+ filterNotice?: string;
80
+ attributionText?: string;
81
+ styleClassPassthrough?: string | string[];
82
+ }
83
+
84
+ const props = withDefaults(defineProps<Props>(), {
85
+ minRating: 0,
86
+ showSummary: true,
87
+ tag: "section",
88
+ ariaLabel: "Google reviews",
89
+ prevLabel: "Previous reviews",
90
+ nextLabel: "Next reviews",
91
+ prevIcon: "ic:outline-keyboard-arrow-left",
92
+ nextIcon: "ic:outline-keyboard-arrow-right",
93
+ ratingLabel: "Rated {rating} out of 5",
94
+ totalLabel: "{count} reviews on Google",
95
+ readMoreLabel: "Read on Google",
96
+ filterNotice: "Showing reviews rated {rating} stars and above, in Google's order.",
97
+ attributionText: "Reviews from Google Maps",
98
+ styleClassPassthrough: () => [],
99
+ });
100
+
101
+ const slots = useSlots();
102
+ const headingId = useId();
103
+ const hasHeading = () => Boolean(slots.heading);
104
+
105
+ const formatLabel = (label: string, value: string | number) =>
106
+ label.replace("{rating}", String(value)).replace("{count}", String(value));
107
+
108
+ const filteredReviews = computed(() => (props.data?.reviews ?? []).filter((review) => review.rating >= props.minRating));
109
+
110
+ const listRef = ref<HTMLUListElement | null>(null);
111
+ const atStart = ref(true);
112
+ const atEnd = ref(true);
113
+ const isOverflowing = ref(false);
114
+
115
+ const updateScrollState = () => {
116
+ const list = listRef.value;
117
+ if (!list) return;
118
+ const offset = Math.abs(list.scrollLeft);
119
+ isOverflowing.value = list.scrollWidth > list.clientWidth + 1;
120
+ atStart.value = offset <= 1;
121
+ atEnd.value = offset + list.clientWidth >= list.scrollWidth - 1;
122
+ };
123
+
124
+ const scrollByCard = (direction: 1 | -1) => {
125
+ const list = listRef.value;
126
+ const item = list?.querySelector<HTMLElement>(".google-reviews-item");
127
+ if (!list || !item) return;
128
+ const styles = getComputedStyle(list);
129
+ const step = item.offsetWidth + (parseFloat(styles.columnGap) || 0);
130
+ const sign = styles.direction === "rtl" ? -1 : 1;
131
+ list.scrollBy({ left: direction * step * sign });
132
+ };
133
+
134
+ let resizeObserver: ResizeObserver | null = null;
135
+
136
+ onMounted(() => {
137
+ updateScrollState();
138
+ resizeObserver = new ResizeObserver(updateScrollState);
139
+ if (listRef.value) resizeObserver.observe(listRef.value);
140
+ });
141
+
142
+ onBeforeUnmount(() => resizeObserver?.disconnect());
143
+
144
+ watch(filteredReviews, async () => {
145
+ await nextTick();
146
+ if (listRef.value) {
147
+ resizeObserver?.disconnect();
148
+ resizeObserver?.observe(listRef.value);
149
+ }
150
+ updateScrollState();
151
+ });
152
+
153
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
154
+
155
+ watch(
156
+ () => props.styleClassPassthrough,
157
+ () => {
158
+ resetElementClasses(props.styleClassPassthrough);
159
+ }
160
+ );
161
+ </script>
162
+
163
+ <style lang="css">
164
+ @layer components {
165
+ .google-reviews {
166
+ display: grid;
167
+ gap: var(--google-reviews-gap, 1.6rem);
168
+ min-inline-size: 0;
169
+
170
+ .google-reviews-header {
171
+ display: grid;
172
+ gap: 0.8rem;
173
+ }
174
+
175
+ .google-reviews-summary {
176
+ display: flex;
177
+ flex-wrap: wrap;
178
+ align-items: center;
179
+ gap: 0.8rem;
180
+ }
181
+
182
+ .google-reviews-summary-rating {
183
+ font-size: var(--google-reviews-summary-rating-font-size, 2.4rem);
184
+ font-weight: 700;
185
+ }
186
+
187
+ .google-reviews-stars {
188
+ --_fill: clamp(0%, var(--_rating, 0) / 5 * 100%, 100%);
189
+
190
+ font-size: var(--google-reviews-star-size, 1.6rem);
191
+ letter-spacing: 0.1em;
192
+ background: linear-gradient(
193
+ 90deg,
194
+ var(--google-reviews-star-colour, var(--amber-05)) var(--_fill),
195
+ var(--google-reviews-star-empty-colour, var(--slate-03)) var(--_fill)
196
+ );
197
+ -webkit-background-clip: text;
198
+ background-clip: text;
199
+ color: transparent;
200
+ }
201
+
202
+ .google-reviews-summary-link {
203
+ color: inherit;
204
+ overflow-wrap: anywhere;
205
+ }
206
+
207
+ .google-reviews-filter-notice,
208
+ .google-reviews-attribution {
209
+ margin: 0;
210
+ overflow-wrap: anywhere;
211
+ font-size: var(--google-reviews-meta-font-size, 1.4rem);
212
+ color: var(--google-reviews-meta-text-colour, inherit);
213
+ }
214
+
215
+ .google-reviews-list {
216
+ display: grid;
217
+ grid-auto-flow: column;
218
+ grid-auto-columns: var(--google-reviews-card-width, min(32rem, 85%));
219
+ grid-template-rows: repeat(5, auto);
220
+ column-gap: var(--google-reviews-gap, 1.6rem);
221
+ row-gap: 0;
222
+ margin: 0;
223
+ padding: 0 0 0.8rem;
224
+ list-style: none;
225
+ overflow-x: auto;
226
+ overscroll-behavior-inline: contain;
227
+ scrollbar-width: var(--google-reviews-scrollbar-width, thin);
228
+ scrollbar-color: var(--google-reviews-scrollbar-thumb-colour, var(--slate-04))
229
+ var(--google-reviews-scrollbar-track-colour, transparent);
230
+ scroll-snap-type: inline mandatory;
231
+ scroll-padding-inline: var(--google-reviews-scroll-padding, 0);
232
+ scroll-behavior: smooth;
233
+
234
+ &:focus-visible {
235
+ outline: var(--google-reviews-focus-outline-width, 2px) solid var(--google-reviews-focus-outline-colour, var(--theme-border-focus));
236
+ outline-offset: 2px;
237
+ }
238
+
239
+ @media (prefers-reduced-motion: reduce) {
240
+ scroll-behavior: auto;
241
+ }
242
+ }
243
+
244
+ /* Cards share the list's five rows (name, date, rating, text, link) so each section lines up across the row */
245
+ .google-reviews-item {
246
+ display: grid;
247
+ grid-row: span 5;
248
+ grid-template-rows: subgrid;
249
+ scroll-snap-align: start;
250
+ min-inline-size: 0;
251
+
252
+ > * {
253
+ grid-row: 1 / -1;
254
+ }
255
+
256
+ > .google-review-card {
257
+ grid-template-rows: subgrid;
258
+ }
259
+ }
260
+
261
+ .google-reviews-footer {
262
+ display: flex;
263
+ flex-wrap: wrap;
264
+ align-items: center;
265
+ justify-content: space-between;
266
+ gap: 1.2rem;
267
+ }
268
+
269
+ .google-reviews-controls {
270
+ display: flex;
271
+ gap: 0.8rem;
272
+ }
273
+ }
274
+ }
275
+ </style>
@@ -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
+ };