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.
- package/.claude/skills/colour-scheme-disable.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +214 -0
- package/.claude/skills/component-inline-action-button.md +2 -2
- package/.claude/skills/components/canvas-switcher.md +5 -1
- package/.claude/skills/components/carousel-flip.md +3 -3
- package/.claude/skills/components/clipped-panel.md +2 -2
- package/.claude/skills/components/dashboard-quad-grid.md +2 -2
- package/.claude/skills/components/dashboard-stats-grid.md +1 -1
- package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
- package/.claude/skills/components/deep-expanding-menu.md +1 -1
- package/.claude/skills/components/eyebrow-text.md +6 -4
- package/.claude/skills/components/google-reviews.md +193 -0
- package/.claude/skills/components/input-number.md +4 -0
- package/.claude/skills/components/input-otp.md +120 -0
- package/.claude/skills/components/section-parallax.md +1 -1
- package/.claude/skills/components/select-menu.md +9 -5
- package/.claude/skills/components/services-card.md +25 -2
- package/.claude/skills/composable-colour-scheme.md +2 -0
- package/.claude/skills/index.md +5 -1
- package/.claude/skills/new-app-scaffold.md +1 -1
- package/.claude/skills/storybook-add-story.md +5 -0
- package/.claude/skills/theming-colour-ramps.md +6 -33
- package/.claude/skills/theming-component-token-pattern.md +30 -0
- package/.claude/skills/theming-dark-mode.md +243 -0
- package/.claude/skills/theming-override-default.md +6 -7
- package/.claude/skills/theming-partial-override.md +11 -11
- package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
- package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
- package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
- package/.vscode/srcdev-component-services-card.code-snippets +38 -7
- package/README.md +81 -41
- package/app/assets/styles/setup/01.config/_head.css +0 -9
- package/app/assets/styles/setup/03.theming/_default.css +29 -29
- package/app/assets/styles/setup/03.theming/_error.css +3 -3
- package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
- package/app/assets/styles/setup/a11y/_variables.css +2 -2
- package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +10 -0
- package/app/components/02.molecules/select-menu/SelectMenu.vue +15 -1
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +3 -3
- package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
- package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
- package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
- package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
- package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
- package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/input-number/InputNumber.vue +2 -2
- package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
- package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
- package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
- package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
- package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
- package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
- package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
- package/app/composables/tests/useAriaDescribedById.spec.ts +5 -5
- package/app/composables/useGoogleReviews.ts +4 -0
- package/app/types/components/google-reviews.d.ts +18 -0
- package/app/types/components/index.ts +1 -0
- package/nuxt.config.ts +9 -9
- package/package.json +9 -8
package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts
CHANGED
|
@@ -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?:
|
|
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: "
|
|
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
|
|
176
|
-
* --description-line-clamp
|
|
177
|
-
*
|
|
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
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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>
|