srcdev-nuxt-components 9.5.3 → 9.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude/skills/colour-scheme-disable.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +214 -0
  3. package/.claude/skills/component-inline-action-button.md +2 -2
  4. package/.claude/skills/components/carousel-flip.md +3 -3
  5. package/.claude/skills/components/clipped-panel.md +2 -2
  6. package/.claude/skills/components/dashboard-quad-grid.md +2 -2
  7. package/.claude/skills/components/dashboard-stats-grid.md +1 -1
  8. package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
  9. package/.claude/skills/components/deep-expanding-menu.md +1 -1
  10. package/.claude/skills/components/google-reviews.md +193 -0
  11. package/.claude/skills/components/input-number.md +4 -0
  12. package/.claude/skills/components/input-otp.md +120 -0
  13. package/.claude/skills/components/section-parallax.md +1 -1
  14. package/.claude/skills/components/select-menu.md +9 -5
  15. package/.claude/skills/composable-colour-scheme.md +2 -0
  16. package/.claude/skills/index.md +5 -1
  17. package/.claude/skills/new-app-scaffold.md +1 -1
  18. package/.claude/skills/theming-colour-ramps.md +6 -33
  19. package/.claude/skills/theming-component-token-pattern.md +30 -0
  20. package/.claude/skills/theming-dark-mode.md +243 -0
  21. package/.claude/skills/theming-override-default.md +6 -7
  22. package/.claude/skills/theming-partial-override.md +11 -11
  23. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  24. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  25. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  26. package/README.md +81 -41
  27. package/app/assets/styles/setup/01.config/_head.css +0 -9
  28. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  29. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  30. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  31. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  32. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  33. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +10 -0
  34. package/app/components/02.molecules/select-menu/SelectMenu.vue +15 -1
  35. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  36. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  37. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +3 -3
  38. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  39. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  40. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  41. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  42. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  43. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  44. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  45. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  46. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  47. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  48. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  49. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  50. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  51. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  52. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  53. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  54. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  55. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  56. package/app/composables/useGoogleReviews.ts +4 -0
  57. package/app/types/components/google-reviews.d.ts +18 -0
  58. package/app/types/components/index.ts +1 -0
  59. package/nuxt.config.ts +9 -9
  60. package/package.json +9 -8
@@ -0,0 +1,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.
@@ -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>
@@ -0,0 +1,106 @@
1
+ <template>
2
+ <FormFieldset
3
+ :id="`${id}-fieldset`"
4
+ :name
5
+ :legend="label"
6
+ :field-has-error
7
+ :required
8
+ :style-class-passthrough="['input-otp-field', elementClasses]"
9
+ :data-invalid="fieldHasError ? '' : null"
10
+ :data-theme="theme"
11
+ >
12
+ <template #content>
13
+ <InputDescription
14
+ :description-id
15
+ :input-variant
16
+ :field-has-error="fieldHasError"
17
+ :style-class-passthrough="['input-otp-description']"
18
+ >
19
+ <template v-if="slots.descriptionHtml" #descriptionHtml>
20
+ <slot name="descriptionHtml"></slot>
21
+ </template>
22
+ <template v-if="slots.descriptionText" #descriptionText>
23
+ <slot name="descriptionText"></slot>
24
+ </template>
25
+ </InputDescription>
26
+
27
+ <InputOtp
28
+ :id
29
+ v-model="modelValue"
30
+ :name
31
+ :length
32
+ :autofocus
33
+ :required
34
+ :digit-label
35
+ :field-has-error
36
+ :theme
37
+ :input-variant
38
+ :aria-describedby="ariaDescribedby() ?? ''"
39
+ @complete="emit('complete', $event)"
40
+ />
41
+
42
+ <InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" :input-variant />
43
+ </template>
44
+ </FormFieldset>
45
+ </template>
46
+
47
+ <script setup lang="ts">
48
+ import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
49
+
50
+ interface Props {
51
+ name: string;
52
+ label: string;
53
+ length?: number;
54
+ autofocus?: boolean;
55
+ required?: boolean;
56
+ digitLabel?: string;
57
+ errorMessage?: object | string;
58
+ fieldHasError?: boolean;
59
+ theme?: FormUiTheme;
60
+ inputVariant?: InputUiVariant;
61
+ styleClassPassthrough?: string | string[];
62
+ }
63
+
64
+ const props = withDefaults(defineProps<Props>(), {
65
+ length: 6,
66
+ autofocus: false,
67
+ required: false,
68
+ digitLabel: "Digit {index} of {length}",
69
+ errorMessage: "",
70
+ fieldHasError: false,
71
+ theme: "default",
72
+ inputVariant: "normal",
73
+ styleClassPassthrough: () => [],
74
+ });
75
+
76
+ const emit = defineEmits<{ complete: [code: string] }>();
77
+
78
+ const modelValue = defineModel<string>({ required: true });
79
+
80
+ const slots = useSlots();
81
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
82
+
83
+ watch(
84
+ () => props.styleClassPassthrough,
85
+ () => resetElementClasses(props.styleClassPassthrough)
86
+ );
87
+
88
+ const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
89
+ props.name,
90
+ toRef(props, "fieldHasError"),
91
+ slots
92
+ );
93
+ </script>
94
+
95
+ <style lang="css">
96
+ @layer components {
97
+ .input-otp-field {
98
+ min-inline-size: 0;
99
+ overflow-wrap: anywhere;
100
+
101
+ .input-otp {
102
+ margin-block-end: var(--input-otp-field-gap, 0.8rem);
103
+ }
104
+ }
105
+ }
106
+ </style>
@@ -0,0 +1,100 @@
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputOtp from "../InputOtp.vue";
4
+ import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
5
+
6
+ interface StoryArgs {
7
+ modelValue: string;
8
+ id: string;
9
+ name: string;
10
+ length: number;
11
+ autofocus: boolean;
12
+ required: boolean;
13
+ groupLabel: string;
14
+ digitLabel: string;
15
+ fieldHasError: boolean;
16
+ theme: FormUiTheme;
17
+ inputVariant: InputUiVariant;
18
+ }
19
+
20
+ const meta: Meta<StoryArgs> = {
21
+ title: "Components/Forms/Input Otp/InputOtp",
22
+ component: InputOtp,
23
+ argTypes: {
24
+ modelValue: { control: "text", table: { category: "Model" } },
25
+ id: { control: "text", table: { category: "Basic" } },
26
+ name: { control: "text", description: "Name of the hidden input holding the full code", table: { category: "Basic" } },
27
+ length: { control: { type: "number", min: 1, max: 12 }, table: { category: "Basic" } },
28
+ autofocus: { control: "boolean", description: "Focus the first box on mount (applies on remount)", table: { category: "Basic" } },
29
+ required: { control: "boolean", table: { category: "Validation" } },
30
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
31
+ groupLabel: { control: "text", description: "Names the group when used without InputOtpField", table: { category: "Accessibility" } },
32
+ digitLabel: { control: "text", description: "Per-box label; {index} and {length} are replaced", table: { category: "Accessibility" } },
33
+ theme: {
34
+ control: { type: "select" },
35
+ options: ["default", "success", "error", "warning"],
36
+ table: { category: "Styling" },
37
+ },
38
+ inputVariant: {
39
+ control: { type: "select" },
40
+ options: ["normal", "underlined"],
41
+ table: { category: "Styling" },
42
+ },
43
+ },
44
+ args: {
45
+ modelValue: "",
46
+ id: "otp",
47
+ name: "code",
48
+ length: 6,
49
+ autofocus: false,
50
+ required: false,
51
+ groupLabel: "Verification code",
52
+ digitLabel: "Digit {index} of {length}",
53
+ fieldHasError: false,
54
+ theme: "default",
55
+ inputVariant: "normal",
56
+ },
57
+ parameters: {
58
+ docs: {
59
+ description: {
60
+ component:
61
+ "Bare one-time-code control: a row of single-digit boxes. Most consumers want `InputOtpField`, which adds the legend, description and error.",
62
+ },
63
+ },
64
+ },
65
+ };
66
+
67
+ export default meta;
68
+ type Story = StoryObj<StoryArgs>;
69
+
70
+ const render = (args: StoryArgs) => ({
71
+ components: { InputOtp },
72
+ setup() {
73
+ const componentArgs = computed(() => {
74
+ const { modelValue: _modelValue, ...rest } = args;
75
+ return rest;
76
+ });
77
+ const lastComplete = ref("");
78
+ return { args, componentArgs, lastComplete };
79
+ },
80
+ template: `
81
+ <div style="margin: 3.6rem;">
82
+ <InputOtp v-model="args.modelValue" v-bind="componentArgs" @complete="lastComplete = $event" />
83
+ <p style="margin-block-start: 1.6rem;">Model: "{{ args.modelValue }}" · last complete: "{{ lastComplete }}"</p>
84
+ </div>
85
+ `,
86
+ });
87
+
88
+ export const Default: Story = { render };
89
+
90
+ export const Prefilled: Story = {
91
+ args: { modelValue: "012345" },
92
+ render,
93
+ parameters: { docs: { description: { story: "A leading zero is kept: the model is a string, never a number." } } },
94
+ };
95
+
96
+ export const FourDigits: Story = { args: { length: 4 }, render };
97
+
98
+ export const WithError: Story = { args: { fieldHasError: true, modelValue: "123" }, render };
99
+
100
+ export const Underlined: Story = { args: { inputVariant: "underlined" }, render };