srcdev-nuxt-components 9.5.2 → 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 (76) 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/canvas-switcher.md +5 -1
  5. package/.claude/skills/components/carousel-flip.md +3 -3
  6. package/.claude/skills/components/clipped-panel.md +2 -2
  7. package/.claude/skills/components/dashboard-quad-grid.md +2 -2
  8. package/.claude/skills/components/dashboard-stats-grid.md +1 -1
  9. package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
  10. package/.claude/skills/components/deep-expanding-menu.md +1 -1
  11. package/.claude/skills/components/eyebrow-text.md +6 -4
  12. package/.claude/skills/components/google-reviews.md +193 -0
  13. package/.claude/skills/components/input-number.md +4 -0
  14. package/.claude/skills/components/input-otp.md +120 -0
  15. package/.claude/skills/components/section-parallax.md +1 -1
  16. package/.claude/skills/components/select-menu.md +9 -5
  17. package/.claude/skills/components/services-card.md +25 -2
  18. package/.claude/skills/composable-colour-scheme.md +2 -0
  19. package/.claude/skills/index.md +5 -1
  20. package/.claude/skills/new-app-scaffold.md +1 -1
  21. package/.claude/skills/storybook-add-story.md +5 -0
  22. package/.claude/skills/theming-colour-ramps.md +6 -33
  23. package/.claude/skills/theming-component-token-pattern.md +30 -0
  24. package/.claude/skills/theming-dark-mode.md +243 -0
  25. package/.claude/skills/theming-override-default.md +6 -7
  26. package/.claude/skills/theming-partial-override.md +11 -11
  27. package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
  28. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  29. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  30. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  31. package/.vscode/srcdev-component-services-card.code-snippets +38 -7
  32. package/README.md +81 -41
  33. package/app/assets/styles/setup/01.config/_head.css +0 -9
  34. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  35. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  36. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  37. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  38. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
  41. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  42. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +10 -0
  43. package/app/components/02.molecules/select-menu/SelectMenu.vue +15 -1
  44. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  45. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  46. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +3 -3
  47. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  48. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  49. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  50. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  51. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  52. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  53. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  54. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  55. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
  56. package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
  57. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
  58. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
  59. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
  60. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
  61. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  62. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  63. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  64. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  65. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  66. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  67. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  68. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  69. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  70. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  71. package/app/composables/tests/useAriaDescribedById.spec.ts +5 -5
  72. package/app/composables/useGoogleReviews.ts +4 -0
  73. package/app/types/components/google-reviews.d.ts +18 -0
  74. package/app/types/components/index.ts +1 -0
  75. package/nuxt.config.ts +9 -9
  76. package/package.json +9 -8
@@ -15,7 +15,9 @@ type StoryArgs = {
15
15
  buttonIcon?: string;
16
16
  styleClassPassthrough?: string | string[];
17
17
  /** Story-only — not a real ServicesCardGrid prop, see useStorySetup below. */
18
- lineClamp?: number;
18
+ lineClamp?: 1 | 2 | 3 | 4 | 5;
19
+ /** Story-only — sets --services-card-grid-column-min-width on the wrapper, see useStorySetup below. */
20
+ cardMinWidth?: "200px" | "250px" | "300px" | "350px" | "400px";
19
21
  };
20
22
 
21
23
  const meta: Meta<StoryArgs> = {
@@ -52,10 +54,17 @@ const meta: Meta<StoryArgs> = {
52
54
  description: "Additional CSS classes applied to the root element",
53
55
  },
54
56
  lineClamp: {
55
- control: { type: "number", min: 1, max: 10, step: 1 },
57
+ control: { type: "inline-radio" },
58
+ options: [1, 2, 3, 4, 5],
56
59
  description:
57
60
  "Story-only control — sets ServicesCard's --description-line-clamp custom property on the grid wrapper (not a real component prop) to demo description clamping across cards",
58
61
  },
62
+ cardMinWidth: {
63
+ control: { type: "inline-radio" },
64
+ options: ["200px", "250px", "300px", "350px", "400px"],
65
+ description:
66
+ "Story-only control — sets --services-card-grid-column-min-width on the grid wrapper (not a real component prop). Cards never go narrower than this; auto-fit then stretches each column to share the leftover space",
67
+ },
59
68
  },
60
69
  args: {
61
70
  tag: "div",
@@ -66,6 +75,7 @@ const meta: Meta<StoryArgs> = {
66
75
  buttonIcon: "mdi:arrow-right",
67
76
  styleClassPassthrough: [],
68
77
  lineClamp: 3,
78
+ cardMinWidth: "250px",
69
79
  },
70
80
  parameters: {
71
81
  backgrounds: {
@@ -169,31 +179,56 @@ const lineClampServices: Service[] = [
169
179
  ),
170
180
  ];
171
181
 
182
+ // Twelve cards (divides evenly into 2, 3, 4 and 6 columns) to demo how cardMinWidth
183
+ // changes the column count as the canvas resizes.
184
+ const cardWidthServices: Service[] = [
185
+ ...sampleServices,
186
+ ...lineClampServices,
187
+ makeService("silk-press", "Silk Press", "Sleek, glossy straightening", "https://picsum.photos/seed/width-1/600/800"),
188
+ makeService("box-braids", "Box Braids", "Classic protective style", "https://picsum.photos/seed/width-2/600/800"),
189
+ makeService("scalp-treatment", "Scalp Treatment", "Healthy hair starts here", "https://picsum.photos/seed/width-3/600/800"),
190
+ ];
191
+
172
192
  // ─── Stories ──────────────────────────────────────────────────────────────────
173
193
 
194
+ // Hides the story-only lineClamp/cardMinWidth controls on stories whose fixtures can't
195
+ // show them (3 or fewer cards never wrap under auto-fit, and two-line descriptions
196
+ // never clamp) — use the Description Line Clamp and Card Width stories for those.
197
+ const hideDemoControls = {
198
+ lineClamp: { table: { disable: true } },
199
+ cardMinWidth: { table: { disable: true } },
200
+ };
201
+
174
202
  /**
175
- * lineClamp is a story-only control (not a real ServicesCardGrid prop) that sets
176
- * --description-line-clamp on the grid wrapper — the global custom property each
177
- * ServicesCard's .description reads for its -webkit-line-clamp value.
203
+ * lineClamp and cardMinWidth are story-only controls (not real ServicesCardGrid props).
204
+ * They set --description-line-clamp (read by each ServicesCard's .description) and
205
+ * --services-card-grid-column-min-width (the grid's minmax() floor) on a wrapper div,
206
+ * and are stripped from the args bound to the component so they don't leak as attributes.
178
207
  */
179
208
  function useStorySetup(args: StoryArgs) {
180
- const clampStyle = computed(() => ({
209
+ const wrapperStyle = computed(() => ({
181
210
  "--description-line-clamp": String(args.lineClamp ?? 3),
211
+ "--services-card-grid-column-min-width": args.cardMinWidth ?? "250px",
182
212
  }));
183
213
  const componentArgs = computed(() => {
184
- const { lineClamp: _lineClamp, ...rest } = args;
214
+ const { lineClamp: _lineClamp, cardMinWidth: _cardMinWidth, ...rest } = args;
185
215
  return rest;
186
216
  });
187
- return { clampStyle, componentArgs };
217
+ return { wrapperStyle, componentArgs };
188
218
  }
189
219
 
190
220
  export const Default: Story = {
221
+ argTypes: hideDemoControls,
191
222
  render: (args) => ({
192
223
  components: { ServicesCardGrid },
193
224
  setup() {
194
- return { args, sampleServices };
225
+ return { ...useStorySetup(args), sampleServices };
195
226
  },
196
- template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
227
+ template: `
228
+ <div :style="wrapperStyle">
229
+ <ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
230
+ </div>
231
+ `,
197
232
  }),
198
233
  parameters: {
199
234
  docs: {
@@ -205,6 +240,7 @@ export const Default: Story = {
205
240
  };
206
241
 
207
242
  export const CustomButtonText: Story = {
243
+ argTypes: hideDemoControls,
208
244
  name: "Custom Button Text",
209
245
  args: {
210
246
  hrefBase: "/services/",
@@ -213,9 +249,13 @@ export const CustomButtonText: Story = {
213
249
  render: (args) => ({
214
250
  components: { ServicesCardGrid },
215
251
  setup() {
216
- return { args, sampleServices };
252
+ return { ...useStorySetup(args), sampleServices };
217
253
  },
218
- template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
254
+ template: `
255
+ <div :style="wrapperStyle">
256
+ <ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
257
+ </div>
258
+ `,
219
259
  }),
220
260
  parameters: {
221
261
  docs: {
@@ -227,6 +267,7 @@ export const CustomButtonText: Story = {
227
267
  };
228
268
 
229
269
  export const ConfigOverrides: Story = {
270
+ argTypes: hideDemoControls,
230
271
  name: "Config Overrides",
231
272
  args: {
232
273
  eyebrowConfig: { tag: "p", fontSize: "small" },
@@ -235,9 +276,13 @@ export const ConfigOverrides: Story = {
235
276
  render: (args) => ({
236
277
  components: { ServicesCardGrid },
237
278
  setup() {
238
- return { args, sampleServices };
279
+ return { ...useStorySetup(args), sampleServices };
239
280
  },
240
- template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
281
+ template: `
282
+ <div :style="wrapperStyle">
283
+ <ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
284
+ </div>
285
+ `,
241
286
  }),
242
287
  parameters: {
243
288
  docs: {
@@ -250,13 +295,18 @@ export const ConfigOverrides: Story = {
250
295
  };
251
296
 
252
297
  export const SingleCard: Story = {
298
+ argTypes: hideDemoControls,
253
299
  name: "Single Card",
254
300
  render: (args) => ({
255
301
  components: { ServicesCardGrid },
256
302
  setup() {
257
- return { args, sampleServices };
303
+ return { ...useStorySetup(args), sampleServices };
258
304
  },
259
- template: `<ServicesCardGrid v-bind="args" :services-data="[sampleServices[0]]" />`,
305
+ template: `
306
+ <div :style="wrapperStyle">
307
+ <ServicesCardGrid v-bind="componentArgs" :services-data="[sampleServices[0]]" />
308
+ </div>
309
+ `,
260
310
  }),
261
311
  parameters: {
262
312
  docs: {
@@ -268,13 +318,18 @@ export const SingleCard: Story = {
268
318
  };
269
319
 
270
320
  export const EmptyData: Story = {
321
+ argTypes: hideDemoControls,
271
322
  name: "Empty Data",
272
323
  render: (args) => ({
273
324
  components: { ServicesCardGrid },
274
325
  setup() {
275
- return { args };
326
+ return { ...useStorySetup(args) };
276
327
  },
277
- template: `<ServicesCardGrid v-bind="args" :services-data="[]" />`,
328
+ template: `
329
+ <div :style="wrapperStyle">
330
+ <ServicesCardGrid v-bind="componentArgs" :services-data="[]" />
331
+ </div>
332
+ `,
278
333
  }),
279
334
  parameters: {
280
335
  docs: {
@@ -296,7 +351,7 @@ export const DescriptionLineClamp: Story = {
296
351
  return { ...useStorySetup(args), lineClampServices };
297
352
  },
298
353
  template: `
299
- <div :style="clampStyle">
354
+ <div :style="wrapperStyle">
300
355
  <ServicesCardGrid v-bind="componentArgs" :services-data="lineClampServices" />
301
356
  </div>
302
357
  `,
@@ -310,3 +365,29 @@ export const DescriptionLineClamp: Story = {
310
365
  },
311
366
  },
312
367
  };
368
+
369
+ export const CardWidth: Story = {
370
+ name: "Card Width",
371
+ args: {
372
+ cardMinWidth: "300px",
373
+ },
374
+ render: (args) => ({
375
+ components: { ServicesCardGrid },
376
+ setup() {
377
+ return { ...useStorySetup(args), cardWidthServices };
378
+ },
379
+ template: `
380
+ <div :style="wrapperStyle">
381
+ <ServicesCardGrid v-bind="componentArgs" :services-data="cardWidthServices" />
382
+ </div>
383
+ `,
384
+ }),
385
+ parameters: {
386
+ docs: {
387
+ description: {
388
+ story:
389
+ "Twelve cards with the cardMinWidth control driving --services-card-grid-column-min-width. The value is a floor, not a fixed width: auto-fit fits as many columns as that minimum allows, then stretches them to fill the row. Change the control or resize the canvas to see the column count change.",
390
+ },
391
+ },
392
+ },
393
+ };
@@ -3,7 +3,10 @@
3
3
  exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
4
4
  "<div class="services-card-grid">
5
5
  <div class="services-card">
6
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="lazy" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
6
+ <div class="grid-stack image-wrapper">
7
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="lazy" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
8
+ <div class="grid-stack__layer"></div>
9
+ </div>
7
10
  <div class="details-wrapper">
8
11
  <div class="eyebrow-text large">Subtitle for Locs Installation</div>
9
12
  <h2 class="hero-text heading axis-horizontal">
@@ -23,7 +26,10 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
23
26
  </div>
24
27
  </div>
25
28
  <div class="services-card">
26
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="lazy" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
29
+ <div class="grid-stack image-wrapper">
30
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="lazy" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
31
+ <div class="grid-stack__layer"></div>
32
+ </div>
27
33
  <div class="details-wrapper">
28
34
  <div class="eyebrow-text large">Subtitle for Locs Retwist</div>
29
35
  <h2 class="hero-text heading axis-horizontal">
@@ -43,7 +49,10 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
43
49
  </div>
44
50
  </div>
45
51
  <div class="services-card">
46
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
52
+ <div class="grid-stack image-wrapper">
53
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
54
+ <div class="grid-stack__layer"></div>
55
+ </div>
47
56
  <div class="details-wrapper">
48
57
  <div class="eyebrow-text large">Subtitle for Colour Treatment</div>
49
58
  <h2 class="hero-text heading axis-horizontal">
@@ -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.
@@ -0,0 +1,254 @@
1
+ <template>
2
+ <div
3
+ class="input-otp"
4
+ :class="[inputVariant, elementClasses]"
5
+ :data-theme="theme"
6
+ :data-invalid="fieldHasError ? '' : null"
7
+ :role="groupLabel ? 'group' : undefined"
8
+ :aria-label="groupLabel || undefined"
9
+ >
10
+ <template v-for="(digit, index) in digits" :key="index">
11
+ <label class="sr-only" :for="boxId(index)">{{ boxLabel(index) }}</label>
12
+ <input
13
+ :id="boxId(index)"
14
+ :ref="(el) => setInputRef(el, index)"
15
+ type="text"
16
+ inputmode="numeric"
17
+ pattern="[0-9]*"
18
+ :autocomplete="index === 0 ? 'one-time-code' : 'off'"
19
+ class="input-otp-box"
20
+ :required
21
+ :value="digit"
22
+ :aria-invalid="fieldHasError ? 'true' : undefined"
23
+ :aria-describedby="ariaDescribedby || undefined"
24
+ @input="onInput(index, $event)"
25
+ @keydown="onKeydown(index, $event)"
26
+ @paste="onPaste(index, $event)"
27
+ @focus="onFocus"
28
+ />
29
+ </template>
30
+ <input v-if="name" type="hidden" :name :value="modelValue" />
31
+ </div>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ import type { ComponentPublicInstance } from "vue";
36
+ import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
37
+
38
+ interface Props {
39
+ id: string;
40
+ name?: string;
41
+ length?: number;
42
+ autofocus?: boolean;
43
+ required?: boolean;
44
+ groupLabel?: string;
45
+ digitLabel?: string;
46
+ fieldHasError?: boolean;
47
+ ariaDescribedby?: string;
48
+ theme?: FormUiTheme;
49
+ inputVariant?: InputUiVariant;
50
+ styleClassPassthrough?: string | string[];
51
+ }
52
+
53
+ const props = withDefaults(defineProps<Props>(), {
54
+ name: "",
55
+ length: 6,
56
+ autofocus: false,
57
+ required: false,
58
+ groupLabel: "",
59
+ digitLabel: "Digit {index} of {length}",
60
+ fieldHasError: false,
61
+ ariaDescribedby: "",
62
+ theme: "default",
63
+ inputVariant: "normal",
64
+ styleClassPassthrough: () => [],
65
+ });
66
+
67
+ const emit = defineEmits<{ complete: [code: string] }>();
68
+
69
+ const modelValue = defineModel<string>({ required: true });
70
+
71
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
72
+
73
+ watch(
74
+ () => props.styleClassPassthrough,
75
+ () => resetElementClasses(props.styleClassPassthrough)
76
+ );
77
+
78
+ const boxCount = computed(() => Math.min(12, Math.max(1, Math.floor(Number(props.length) || 1))));
79
+
80
+ const slotsFromModel = () =>
81
+ (modelValue.value ?? "")
82
+ .padEnd(boxCount.value, " ")
83
+ .slice(0, boxCount.value)
84
+ .split("")
85
+ .map((char) => (/\d/.test(char) ? char : " "));
86
+
87
+ const digits = computed(() => slotsFromModel().map((char) => (char === " " ? "" : char)));
88
+
89
+ const boxId = (index: number) => `${props.id}-${index}`;
90
+ const boxLabel = (index: number) =>
91
+ props.digitLabel.replace("{index}", String(index + 1)).replace("{length}", String(boxCount.value));
92
+
93
+ const inputRefs = ref<(HTMLInputElement | null)[]>([]);
94
+ const setInputRef = (el: Element | ComponentPublicInstance | null, index: number) => {
95
+ inputRefs.value[index] = el as HTMLInputElement | null;
96
+ };
97
+
98
+ const focusBox = (index: number) => {
99
+ const clamped = Math.min(boxCount.value - 1, Math.max(0, index));
100
+ inputRefs.value[clamped]?.focus();
101
+ };
102
+
103
+ onMounted(() => {
104
+ if (props.autofocus) nextTick(() => focusBox(0));
105
+ });
106
+
107
+ watch(
108
+ () => props.fieldHasError,
109
+ (hasError) => {
110
+ if (hasError) nextTick(() => focusBox(0));
111
+ }
112
+ );
113
+
114
+ function commit(next: string[]) {
115
+ const code = next.join("").trimEnd();
116
+ modelValue.value = code;
117
+ if (next.every((char) => char !== " ")) emit("complete", code);
118
+ }
119
+
120
+ function fillFrom(index: number, incoming: string) {
121
+ const next = slotsFromModel();
122
+ const chars = incoming.slice(0, boxCount.value - index).split("");
123
+ chars.forEach((char, offset) => {
124
+ next[index + offset] = char;
125
+ });
126
+ commit(next);
127
+ return index + chars.length - 1;
128
+ }
129
+
130
+ function onInput(index: number, event: Event) {
131
+ const target = event.target as HTMLInputElement;
132
+ const previous = digits.value[index];
133
+ let entered = target.value.replace(/\D/g, "");
134
+
135
+ if (previous && entered.length === 2) {
136
+ if (entered.startsWith(previous)) entered = entered.slice(1);
137
+ else if (entered.endsWith(previous)) entered = entered.slice(0, 1);
138
+ }
139
+
140
+ if (entered.length > 1) {
141
+ const lastFilled = fillFrom(index, entered);
142
+ target.value = digits.value[index] ?? "";
143
+ focusBox(lastFilled);
144
+ return;
145
+ }
146
+
147
+ target.value = entered;
148
+ const next = slotsFromModel();
149
+ next[index] = entered || " ";
150
+ commit(next);
151
+
152
+ if (entered && index < boxCount.value - 1) focusBox(index + 1);
153
+ }
154
+
155
+ function onKeydown(index: number, event: KeyboardEvent) {
156
+ const target = event.target as HTMLInputElement;
157
+
158
+ switch (event.key) {
159
+ case "Enter":
160
+ event.preventDefault();
161
+ target.form?.requestSubmit();
162
+ return;
163
+ case "Backspace":
164
+ if (!target.value && index > 0) {
165
+ event.preventDefault();
166
+ const next = slotsFromModel();
167
+ next[index - 1] = " ";
168
+ commit(next);
169
+ focusBox(index - 1);
170
+ }
171
+ return;
172
+ case "ArrowLeft":
173
+ event.preventDefault();
174
+ focusBox(index - 1);
175
+ return;
176
+ case "ArrowRight":
177
+ event.preventDefault();
178
+ focusBox(index + 1);
179
+ return;
180
+ case "Home":
181
+ event.preventDefault();
182
+ focusBox(0);
183
+ return;
184
+ case "End":
185
+ event.preventDefault();
186
+ focusBox(boxCount.value - 1);
187
+ return;
188
+ }
189
+ }
190
+
191
+ function onPaste(index: number, event: ClipboardEvent) {
192
+ const pasted = (event.clipboardData?.getData("text") ?? "").replace(/\D/g, "");
193
+ event.preventDefault();
194
+ if (!pasted) return;
195
+
196
+ const start = pasted.length >= boxCount.value ? 0 : index;
197
+ focusBox(fillFrom(start, pasted));
198
+ }
199
+
200
+ function onFocus(event: FocusEvent) {
201
+ (event.target as HTMLInputElement).select();
202
+ }
203
+ </script>
204
+
205
+ <style lang="css">
206
+ @layer components {
207
+ .input-otp {
208
+ --_border: var(--input-otp-border, var(--theme-border));
209
+ --_border-focus: var(--input-otp-border-focus, var(--theme-border-focus));
210
+
211
+ display: flex;
212
+ gap: var(--input-otp-gap, 0.8rem);
213
+ max-inline-size: 100%;
214
+
215
+ .input-otp-box {
216
+ all: unset;
217
+ box-sizing: border-box;
218
+ flex: 0 1 var(--input-otp-box-inline-size, 4.4rem);
219
+ min-inline-size: 0;
220
+ block-size: var(--input-otp-box-block-size, 5.2rem);
221
+ text-align: center;
222
+ font-family: var(--font-family);
223
+ font-size: var(--input-otp-font-size, 2rem);
224
+ color: var(--input-otp-text-color, var(--theme-input-text-color-normal));
225
+ background-color: var(--input-otp-surface, var(--theme-input-surface));
226
+ transition: outline-color var(--theme-form-transition-duration) ease-in-out;
227
+ }
228
+
229
+ &.normal .input-otp-box {
230
+ border: var(--form-element-border-width) solid var(--_border);
231
+ border-radius: var(--input-otp-border-radius, var(--form-input-border-radius));
232
+ outline: var(--form-element-outline-width) solid transparent;
233
+
234
+ &:hover {
235
+ outline: var(--form-element-outline-width-focus) solid var(--input-otp-border-hover, var(--_border-focus));
236
+ outline-offset: var(--form-element-outline-offset-focus);
237
+ }
238
+
239
+ &:focus-visible {
240
+ outline: var(--form-element-outline-width-focus) solid var(--_border-focus);
241
+ outline-offset: var(--form-element-outline-offset-focus);
242
+ }
243
+ }
244
+
245
+ &.underlined .input-otp-box {
246
+ border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
247
+
248
+ &:focus-visible {
249
+ border-bottom-color: var(--_border-focus);
250
+ }
251
+ }
252
+ }
253
+ }
254
+ </style>