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,110 @@
1
+ # GoogleReviews — Consumer Styling Guide
2
+
3
+ `GoogleReviews` shows a place's Google reviews as a single horizontally scrolling row of
4
+ `GoogleReviewCard`s, with a summary above and prev/next buttons plus the Google Maps attribution
5
+ below. Both components read the same `--google-reviews-*` tokens, so setting them on an ancestor
6
+ styles a standalone `GoogleReviewCard` too.
7
+
8
+ ## Public token API
9
+
10
+ ### Row (`GoogleReviews`)
11
+
12
+ | Token | Default | Controls |
13
+ |---|---|---|
14
+ | `--google-reviews-gap` | `1.6rem` | Space between header, row and footer, and between cards |
15
+ | `--google-reviews-card-width` | `min(32rem, 85%)` | Width of each card in the row |
16
+ | `--google-reviews-scroll-padding` | `0` | Snap inset, e.g. to line cards up with a page gutter |
17
+ | `--google-reviews-scrollbar-width` | `thin` | Row scrollbar: `auto`, `thin` or `none` (`none` hides it; the buttons and touch/keyboard scrolling still work) |
18
+ | `--google-reviews-scrollbar-thumb-colour` | `var(--slate-04)` | Scrollbar thumb |
19
+ | `--google-reviews-scrollbar-track-colour` | `transparent` | Scrollbar track |
20
+ | `--google-reviews-summary-rating-font-size` | `2.4rem` | Overall rating number |
21
+ | `--google-reviews-focus-outline-width` | `2px` | Focus ring on the scrollable row |
22
+ | `--google-reviews-focus-outline-colour` | `var(--theme-border-focus)` | Focus ring colour |
23
+
24
+ ### Card (`GoogleReviewCard`)
25
+
26
+ | Token | Default | Controls |
27
+ |---|---|---|
28
+ | `--google-reviews-card-gap` | `1.2rem` | Space between author, stars, text and link |
29
+ | `--google-reviews-card-padding` | `2rem` | Card padding |
30
+ | `--google-reviews-card-border-width` | `1px` | Card border width |
31
+ | `--google-reviews-card-border-colour` | `transparent` | Card border colour |
32
+ | `--google-reviews-card-border-radius` | `0.8rem` | Card corner radius |
33
+ | `--google-reviews-card-surface` | `var(--theme-surface-subtle)` | Card background |
34
+ | `--google-reviews-card-text-colour` | `var(--theme-text)` | Card text colour |
35
+ | `--google-reviews-avatar-surface` | `var(--theme-surface)` | Initials avatar background (shown when a reviewer has no photo) |
36
+ | `--google-reviews-avatar-text-colour` | `var(--theme-on-surface)` | Initials colour |
37
+ | `--google-reviews-author-font-weight` | `600` | Reviewer name weight |
38
+ | `--google-reviews-author-line-clamp` | `2` | Lines of reviewer name before it's cut off with an ellipsis (`1` for single-line ellipsis, `none` to show it all) |
39
+ | `--google-reviews-text-line-height` | `1.5` | Review text line height |
40
+ | `--google-reviews-text-line-clamp` | `5` | Lines of review text before it's cut off with an ellipsis (`none` to show it all) |
41
+ | `--google-reviews-link-colour` | `inherit` | "Read on Google" link colour |
42
+
43
+ ### Shared
44
+
45
+ | Token | Default | Controls |
46
+ |---|---|---|
47
+ | `--google-reviews-star-size` | `1.6rem` | Star font size (summary and cards) |
48
+ | `--google-reviews-star-colour` | `var(--amber-05)` | Filled star colour |
49
+ | `--google-reviews-star-empty-colour` | `var(--slate-03)` | Unfilled star colour |
50
+ | `--google-reviews-meta-font-size` | `1.4rem` | Date, link, filter notice and attribution |
51
+ | `--google-reviews-meta-text-colour` | `inherit` | Colour of the same |
52
+
53
+ Private tokens (not public API): `--_rating` (set inline from the review's rating) and `--_fill`
54
+ (the star fill percentage derived from it).
55
+
56
+ ## State hooks
57
+
58
+ - No `data-*` state. The prev/next buttons are `InputButton`s with `readonly`/`aria-disabled` at
59
+ either end of the row, styled by `InputButton`'s own tokens.
60
+ - Inner classes: `.google-reviews-header`, `.google-reviews-summary`,
61
+ `.google-reviews-summary-rating`, `.google-reviews-stars`, `.google-reviews-summary-link`,
62
+ `.google-reviews-filter-notice`, `.google-reviews-list`, `.google-reviews-item`,
63
+ `.google-reviews-footer`, `.google-reviews-controls`, `.google-reviews-prev`,
64
+ `.google-reviews-next`, `.google-reviews-attribution`.
65
+ - Card classes: `.google-review-card`, `.google-review-card-author`,
66
+ `.google-review-card-author-name`,
67
+ `.google-review-card-time`, `.google-review-card-rating`, `.google-review-card-stars`,
68
+ `.google-review-card-text`, `.google-review-card-link`.
69
+
70
+ ## Row alignment
71
+
72
+ Inside `GoogleReviews`, every card spans the list's five shared rows (name, date, rating, text,
73
+ link) through `grid-template-rows: subgrid`, so each section lines up across the whole row
74
+ however tall its neighbours are: a one-line name's date sits level with a two-line name's. The
75
+ author block is itself a subgrid over the name and date rows, with the avatar spanning both.
76
+ Sections sit in fixed rows, so a card with no date, text or link leaves the gap rather than
77
+ shifting up. Spacing between sections is a top margin (`--google-reviews-card-gap`), not a grid
78
+ gap, so the name and date stay close together. A standalone `GoogleReviewCard` uses
79
+ `auto auto auto 1fr auto` instead (link pinned to the bottom). Content from the `card` slot
80
+ spans all five rows as one block. Browsers
81
+ without subgrid fall back to equal-height cards that aren't aligned section by section.
82
+
83
+ ## Stars
84
+
85
+ Stars are the text `★★★★★` filled with a hard-stop gradient clipped to the text, so fractional
86
+ ratings (4.6) show a partly filled star. They're `aria-hidden`; the rating is read out from
87
+ visually hidden text (`ratingLabel`).
88
+
89
+ ## Local overrides
90
+
91
+ Set the tokens above on an element you own (a page or section class, or a class added with
92
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
93
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
94
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
95
+ `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
96
+
97
+ **Caveat:** the card re-declares `--display-avatar-background` and `--display-avatar-text-colour` on its
98
+ avatar, so setting those `DisplayAvatar` tokens on an ancestor has no effect here. Use
99
+ `--google-reviews-avatar-surface` and `--google-reviews-avatar-text-colour` instead.
100
+
101
+ ## Class passthrough
102
+
103
+ `style-class-passthrough` adds classes to the root (`.google-reviews` or `.google-review-card`).
104
+ `GoogleReviews` doesn't forward it to the cards it renders; set tokens on the root instead, or
105
+ use the `card` slot to render `GoogleReviewCard` yourself with its own passthrough.
106
+
107
+ ## Notes
108
+
109
+ Google's terms require the attribution text to stay visible and readable. Restyle it, but don't
110
+ hide it, and keep the words "Google Maps" in `attributionText` when translating.
@@ -0,0 +1,161 @@
1
+ <template>
2
+ <component :is="tag" class="google-review-card" :class="elementClasses">
3
+ <div class="google-review-card-author">
4
+ <DisplayAvatar :src="review.authorPhotoUri" :text="initials" size="md" aria-hidden="true" />
5
+ <a v-if="review.authorUri" :id="authorId" :href="review.authorUri" class="google-review-card-author-name">
6
+ {{ review.authorName }}
7
+ </a>
8
+ <span v-else :id="authorId" class="google-review-card-author-name">{{ review.authorName }}</span>
9
+ <time v-if="review.relativeTime" :datetime="review.publishTime || undefined" class="google-review-card-time">
10
+ {{ review.relativeTime }}
11
+ </time>
12
+ </div>
13
+
14
+ <div class="google-review-card-rating">
15
+ <span class="google-review-card-stars" :style="{ '--_rating': review.rating }" aria-hidden="true">★★★★★</span>
16
+ <span class="sr-only">{{ formattedRatingLabel }}</span>
17
+ </div>
18
+
19
+ <p v-if="review.text" class="google-review-card-text">{{ review.text }}</p>
20
+
21
+ <a v-if="review.reviewUri" :href="review.reviewUri" :aria-describedby="authorId" class="google-review-card-link">
22
+ {{ readMoreLabel }}
23
+ </a>
24
+ </component>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import type { GoogleReview } from "~/types/components";
29
+
30
+ interface Props {
31
+ review: GoogleReview;
32
+ tag?: "article" | "div";
33
+ ratingLabel?: string;
34
+ readMoreLabel?: string;
35
+ styleClassPassthrough?: string | string[];
36
+ }
37
+
38
+ const props = withDefaults(defineProps<Props>(), {
39
+ tag: "article",
40
+ ratingLabel: "Rated {rating} out of 5",
41
+ readMoreLabel: "Read on Google",
42
+ styleClassPassthrough: () => [],
43
+ });
44
+
45
+ const authorId = useId();
46
+ const initials = computed(() =>
47
+ props.review.authorName
48
+ .split(/\s+/)
49
+ .filter(Boolean)
50
+ .slice(0, 2)
51
+ .map((word) => Array.from(word)[0]!.toUpperCase())
52
+ .join("")
53
+ );
54
+ const formattedRatingLabel = computed(() => props.ratingLabel.replace("{rating}", String(props.review.rating)));
55
+
56
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
57
+
58
+ watch(
59
+ () => props.styleClassPassthrough,
60
+ () => {
61
+ resetElementClasses(props.styleClassPassthrough);
62
+ }
63
+ );
64
+ </script>
65
+
66
+ <style lang="css">
67
+ @layer components {
68
+ .google-review-card {
69
+ display: grid;
70
+ grid-template-rows: auto auto auto 1fr auto;
71
+ align-items: start;
72
+ block-size: 100%;
73
+ padding: var(--google-reviews-card-padding, 2rem);
74
+ border: var(--google-reviews-card-border-width, 1px) solid var(--google-reviews-card-border-colour, transparent);
75
+ border-radius: var(--google-reviews-card-border-radius, 0.8rem);
76
+ background-color: var(--google-reviews-card-surface, var(--theme-surface-subtle));
77
+ color: var(--google-reviews-card-text-colour, var(--theme-text));
78
+
79
+ /* Name and date take a row each so both line up across a GoogleReviews row */
80
+ .google-review-card-author {
81
+ grid-row: 1 / span 2;
82
+ display: grid;
83
+ grid-template-columns: auto minmax(0, 1fr);
84
+ grid-template-rows: subgrid;
85
+ align-items: start;
86
+ column-gap: 1.2rem;
87
+ }
88
+
89
+ .google-review-card-author .display-avatar {
90
+ --display-avatar-background: var(--google-reviews-avatar-surface, var(--theme-surface));
91
+ --display-avatar-text-colour: var(--google-reviews-avatar-text-colour, var(--theme-on-surface));
92
+
93
+ grid-column: 1;
94
+ grid-row: 1 / -1;
95
+ }
96
+
97
+ .google-review-card-author-name {
98
+ grid-column: 2;
99
+ grid-row: 1;
100
+ display: -webkit-box;
101
+ -webkit-box-orient: vertical;
102
+ overflow: hidden;
103
+ -webkit-line-clamp: var(--google-reviews-author-line-clamp, 2);
104
+ line-clamp: var(--google-reviews-author-line-clamp, 2);
105
+ font-weight: var(--google-reviews-author-font-weight, 600);
106
+ color: inherit;
107
+ overflow-wrap: anywhere;
108
+ }
109
+
110
+ .google-review-card-time {
111
+ grid-column: 2;
112
+ grid-row: 2;
113
+ font-size: var(--google-reviews-meta-font-size, 1.4rem);
114
+ color: var(--google-reviews-meta-text-colour, inherit);
115
+ }
116
+
117
+ .google-review-card-rating {
118
+ grid-row: 3;
119
+ margin-block-start: var(--google-reviews-card-gap, 1.2rem);
120
+ }
121
+
122
+ .google-review-card-stars {
123
+ --_fill: clamp(0%, var(--_rating, 0) / 5 * 100%, 100%);
124
+
125
+ font-size: var(--google-reviews-star-size, 1.6rem);
126
+ letter-spacing: 0.1em;
127
+ background: linear-gradient(
128
+ 90deg,
129
+ var(--google-reviews-star-colour, var(--amber-05)) var(--_fill),
130
+ var(--google-reviews-star-empty-colour, var(--slate-03)) var(--_fill)
131
+ );
132
+ -webkit-background-clip: text;
133
+ background-clip: text;
134
+ color: transparent;
135
+ }
136
+
137
+ .google-review-card-text {
138
+ grid-row: 4;
139
+ margin-block-start: var(--google-reviews-card-gap, 1.2rem);
140
+ margin-inline: 0;
141
+ margin-block-end: 0;
142
+ white-space: pre-line;
143
+ overflow-wrap: anywhere;
144
+ line-height: var(--google-reviews-text-line-height, 1.5);
145
+ display: -webkit-box;
146
+ -webkit-box-orient: vertical;
147
+ overflow: hidden;
148
+ -webkit-line-clamp: var(--google-reviews-text-line-clamp, 5);
149
+ line-clamp: var(--google-reviews-text-line-clamp, 5);
150
+ }
151
+
152
+ .google-review-card-link {
153
+ grid-row: 5;
154
+ margin-block-start: var(--google-reviews-card-gap, 1.2rem);
155
+ justify-self: start;
156
+ color: var(--google-reviews-link-colour, inherit);
157
+ font-size: var(--google-reviews-meta-font-size, 1.4rem);
158
+ }
159
+ }
160
+ }
161
+ </style>
@@ -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>