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,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
+ `;
@@ -0,0 +1,43 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`GoogleReviews > renders correct HTML structure 1`] = `
4
+ "<section class="google-reviews" aria-label="Google reviews">
5
+ <div class="google-reviews-header">
6
+ <div class="google-reviews-summary"><span class="google-reviews-summary-rating" aria-hidden="true">4.7</span><span class="google-reviews-stars" style="--_rating: 4.66;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 4.7 out of 5</span><a href="https://maps.google.com/?cid=1" class="google-reviews-summary-link">128 reviews on Google</a></div>
7
+ </div>
8
+ <!--v-if-->
9
+ <ul class="google-reviews-list" tabindex="0">
10
+ <li class="google-reviews-item">
11
+ <article class="google-review-card">
12
+ <div class="google-review-card-author"><span class="display-avatar md" aria-hidden="true"><span>R1</span>
13
+ <!--v-if--></span><a id="v-0-0-0" href="https://maps.google.com/contrib/1" class="google-review-card-author-name">Reviewer 1</a><time datetime="2026-09-18T10:00:00Z" class="google-review-card-time">1 weeks ago</time>
14
+ </div>
15
+ <div class="google-review-card-rating"><span class="google-review-card-stars" style="--_rating: 5;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 5 out of 5</span></div>
16
+ <p class="google-review-card-text">Review text 1</p><a href="https://maps.google.com/review/1" aria-describedby="v-0-0-0" class="google-review-card-link">Read on Google</a>
17
+ </article>
18
+ </li>
19
+ <li class="google-reviews-item">
20
+ <article class="google-review-card">
21
+ <div class="google-review-card-author"><span class="display-avatar md" aria-hidden="true"><span>R2</span>
22
+ <!--v-if--></span><a id="v-0-0-1" href="https://maps.google.com/contrib/2" class="google-review-card-author-name">Reviewer 2</a><time datetime="2026-09-18T10:00:00Z" class="google-review-card-time">2 weeks ago</time>
23
+ </div>
24
+ <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>
25
+ <p class="google-review-card-text">Review text 2</p><a href="https://maps.google.com/review/2" aria-describedby="v-0-0-1" class="google-review-card-link">Read on Google</a>
26
+ </article>
27
+ </li>
28
+ <li class="google-reviews-item">
29
+ <article class="google-review-card">
30
+ <div class="google-review-card-author"><span class="display-avatar md" aria-hidden="true"><span>R3</span>
31
+ <!--v-if--></span><a id="v-0-0-2" href="https://maps.google.com/contrib/3" class="google-review-card-author-name">Reviewer 3</a><time datetime="2026-09-18T10:00:00Z" class="google-review-card-time">3 weeks ago</time>
32
+ </div>
33
+ <div class="google-review-card-rating"><span class="google-review-card-stars" style="--_rating: 3;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 3 out of 5</span></div>
34
+ <p class="google-review-card-text">Review text 3</p><a href="https://maps.google.com/review/3" aria-describedby="v-0-0-2" class="google-review-card-link">Read on Google</a>
35
+ </article>
36
+ </li>
37
+ </ul>
38
+ <div class="google-reviews-footer">
39
+ <!--v-if-->
40
+ <p class="google-reviews-attribution">Reviews from Google Maps</p>
41
+ </div>
42
+ </section>"
43
+ `;
@@ -18,7 +18,7 @@ token, same as `InputTextCore`.
18
18
  | `--input-number-surface` | `var(--theme-input-surface)` | Wrapper, input, and embedded-button background |
19
19
  | `--input-number-surface-hover` | `var(--theme-input-surface-hover)` | Embedded left/right button background on hover |
20
20
  | `--input-number-border` | `var(--theme-border)` | Border/divider colour (`normal`/`underlined` variant border, and the divider between the input and an embedded button) |
21
- | `--input-number-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover (`normal` variant only) |
21
+ | `--input-number-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover over the input or either step button (`normal` variant only) |
22
22
  | `--input-number-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` variant), and the embedded button's own `:focus-visible` outline |
23
23
  | `--input-number-text-color` | `var(--theme-input-text-color-normal)` | Input text colour |
24
24
  | `--input-number-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
@@ -104,13 +104,13 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
104
104
  border-radius: var(--form-input-border-radius);
105
105
  outline: var(--form-element-outline-width) solid transparent;
106
106
 
107
- &:has(input:hover) {
107
+ &:has(input:hover, button:hover) {
108
108
  outline: var(--form-element-outline-width-focus) solid
109
109
  var(--input-number-border-hover, var(--theme-border-focus));
110
110
  outline-offset: var(--form-element-outline-offset-focus);
111
111
  }
112
112
 
113
- &:has(input:focus-visible) {
113
+ &:has(input:focus-visible, button:focus-visible) {
114
114
  outline: var(--form-element-outline-width-focus) solid var(--_border-focus);
115
115
  outline-offset: var(--form-element-outline-offset-focus);
116
116
  }
@@ -0,0 +1,59 @@
1
+ # InputOtp — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ Colour tokens fall back to the shared `--theme-*` tokens, so leaving them unset keeps the
6
+ component in step with every other themed form control. See `theming-component-token-pattern.md`
7
+ for the two-tier shape.
8
+
9
+ ### Boxes (`InputOtp`)
10
+
11
+ | Token | Default | Controls |
12
+ |---|---|---|
13
+ | `--input-otp-gap` | `0.8rem` | Space between boxes |
14
+ | `--input-otp-box-inline-size` | `4.4rem` | Preferred box width (boxes shrink below it when the row is too narrow) |
15
+ | `--input-otp-box-block-size` | `5.2rem` | Box height |
16
+ | `--input-otp-font-size` | `2rem` | Digit size |
17
+ | `--input-otp-border-radius` | `var(--form-input-border-radius)` | Box corner radius (`normal` variant) |
18
+ | `--input-otp-surface` | `var(--theme-input-surface)` | Box background |
19
+ | `--input-otp-text-color` | `var(--theme-input-text-color-normal)` | Digit colour |
20
+ | `--input-otp-border` | `var(--theme-border)` | Box border (`normal`) or bottom border (`underlined`) |
21
+ | `--input-otp-border-hover` | `var(--input-otp-border-focus)` | Outline colour on hover (`normal` variant) |
22
+ | `--input-otp-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible`, and the bottom border colour on focus (`underlined`) |
23
+
24
+ ### Field (`InputOtpField`)
25
+
26
+ | Token | Default | Controls |
27
+ |---|---|---|
28
+ | `--input-otp-field-gap` | `0.8rem` | Space between the boxes and the error strip |
29
+
30
+ The legend uses `FormFieldset`'s `--form-fieldset-legend-*` tokens (see its CONSUMER-STYLING.md).
31
+ Border/outline widths and transition duration are the shared form geometry tokens
32
+ (`--form-element-border-width`, `--form-element-outline-width-focus`, etc.), see
33
+ `theming-form-geometry-tokens.md`.
34
+
35
+ Private tokens (not public API): `--_border` and `--_border-focus` on `.input-otp`, which only
36
+ resolve the two public border tokens once for reuse across the variant rules.
37
+
38
+ ## State hooks
39
+
40
+ | Hook | Where | Meaning |
41
+ |---|---|---|
42
+ | `[data-invalid]` | `.input-otp`, and the `.input-otp-field` fieldset | Error state. Switches the colour ramp to the error colours, so `--theme-border` (and everything defaulting from it) turns red without a separate error token. |
43
+ | `[data-theme]` | `.input-otp`, `.input-otp-field` | The `theme` prop. |
44
+ | `.normal` / `.underlined` | `.input-otp` | The `inputVariant` prop. |
45
+ | `.input-otp-box` | each digit `<input>` | One box. |
46
+
47
+ ## Local overrides
48
+
49
+ Set the tokens above on an element you own (a page or section class, or a class added with
50
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
51
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
52
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
53
+ component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
54
+
55
+ ## Class passthrough
56
+
57
+ - `InputOtp`: `style-class-passthrough` classes land on the `.input-otp` root (the row of boxes).
58
+ - `InputOtpField`: `style-class-passthrough` classes land on the `.input-otp-field` fieldset; the
59
+ inner `InputOtp` gets none.