srcdev-nuxt-components 9.5.3 → 9.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude/skills/colour-scheme-disable.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +214 -0
  3. package/.claude/skills/component-inline-action-button.md +2 -2
  4. package/.claude/skills/components/carousel-flip.md +3 -3
  5. package/.claude/skills/components/clipped-panel.md +2 -2
  6. package/.claude/skills/components/dashboard-quad-grid.md +2 -2
  7. package/.claude/skills/components/dashboard-stats-grid.md +1 -1
  8. package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
  9. package/.claude/skills/components/deep-expanding-menu.md +1 -1
  10. package/.claude/skills/components/google-reviews.md +193 -0
  11. package/.claude/skills/components/input-number.md +4 -0
  12. package/.claude/skills/components/input-otp.md +120 -0
  13. package/.claude/skills/components/section-parallax.md +1 -1
  14. package/.claude/skills/components/select-menu.md +19 -5
  15. package/.claude/skills/components/triple-toggle-switch.md +7 -1
  16. package/.claude/skills/composable-colour-scheme.md +2 -0
  17. package/.claude/skills/index.md +5 -1
  18. package/.claude/skills/new-app-scaffold.md +1 -1
  19. package/.claude/skills/theming-colour-ramps.md +6 -33
  20. package/.claude/skills/theming-component-token-pattern.md +30 -0
  21. package/.claude/skills/theming-dark-mode.md +243 -0
  22. package/.claude/skills/theming-override-default.md +6 -7
  23. package/.claude/skills/theming-partial-override.md +11 -11
  24. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  25. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  26. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  27. package/README.md +81 -41
  28. package/app/assets/styles/setup/01.config/_head.css +0 -9
  29. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  30. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  31. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  32. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  33. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  34. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +19 -1
  35. package/app/components/02.molecules/select-menu/SelectMenu.vue +54 -3
  36. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  37. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  38. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +12 -12
  39. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  40. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  41. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  42. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  43. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  44. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  45. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  46. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  47. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  48. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  49. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  50. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  51. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  52. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  53. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  54. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  55. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  56. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  57. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +11 -3
  58. package/app/composables/useGoogleReviews.ts +4 -0
  59. package/app/types/components/google-reviews.d.ts +18 -0
  60. package/app/types/components/index.ts +1 -0
  61. package/nuxt.config.ts +9 -9
  62. package/package.json +9 -8
@@ -0,0 +1,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 };
@@ -0,0 +1,163 @@
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputOtpField from "../InputOtpField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+
8
+ interface StoryArgs {
9
+ modelValue: string;
10
+ name: string;
11
+ label: string;
12
+ length: number;
13
+ autofocus: boolean;
14
+ required: boolean;
15
+ digitLabel: string;
16
+ errorMessage: string;
17
+ fieldHasError: boolean;
18
+ theme: FormUiTheme;
19
+ inputVariant: InputUiVariant;
20
+ descriptionText: string;
21
+ }
22
+
23
+ const meta: Meta<StoryArgs> = {
24
+ title: "Components/Forms/Input Otp/InputOtpField",
25
+ component: InputOtpField,
26
+ argTypes: {
27
+ modelValue: { control: "text", table: { category: "Model" } },
28
+ name: { control: "text", table: { category: "Basic" } },
29
+ label: { control: "text", table: { category: "Basic" } },
30
+ length: { control: { type: "number", min: 1, max: 12 }, table: { category: "Basic" } },
31
+ autofocus: { control: "boolean", description: "Focus the first box on mount (applies on remount)", table: { category: "Basic" } },
32
+ required: { control: "boolean", table: { category: "Validation" } },
33
+ errorMessage: { control: "text", table: { category: "Validation" } },
34
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
35
+ digitLabel: { control: "text", description: "Per-box label; {index} and {length} are replaced", table: { category: "Accessibility" } },
36
+ descriptionText: { control: "text", description: "Fills the descriptionText slot", table: { category: "Slots" } },
37
+ theme: {
38
+ control: { type: "select" },
39
+ options: ["default", "success", "error", "warning"],
40
+ table: { category: "Styling" },
41
+ },
42
+ inputVariant: {
43
+ control: { type: "select" },
44
+ options: ["normal", "underlined"],
45
+ table: { category: "Styling" },
46
+ },
47
+ },
48
+ args: {
49
+ modelValue: "",
50
+ name: "code",
51
+ label: "Verification code",
52
+ length: 6,
53
+ autofocus: false,
54
+ required: false,
55
+ digitLabel: "Digit {index} of {length}",
56
+ errorMessage: "",
57
+ fieldHasError: false,
58
+ theme: "default",
59
+ inputVariant: "normal",
60
+ descriptionText: "Enter the 6-digit code we sent to your email.",
61
+ },
62
+ decorators: [
63
+ (story, context) => ({
64
+ components: { story, CanvasSwitcher },
65
+ setup() {
66
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
67
+ return { canvasName };
68
+ },
69
+ template: `
70
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
71
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
72
+ </div>
73
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
74
+ <story />
75
+ </div>
76
+ `,
77
+ }),
78
+ ],
79
+ parameters: {
80
+ layout: "fullscreen",
81
+ docs: {
82
+ description: {
83
+ component:
84
+ "Labelled one-time-code field: a fieldset legend, optional description, the `InputOtp` boxes and an error strip. Try typing, Backspace, the arrow keys, Home/End, Enter (submits the form) and pasting a code.",
85
+ },
86
+ },
87
+ },
88
+ };
89
+
90
+ export default meta;
91
+ type Story = StoryObj<StoryArgs>;
92
+
93
+ const render = (args: StoryArgs) => ({
94
+ components: { InputOtpField },
95
+ setup() {
96
+ const componentArgs = computed(() => {
97
+ const { modelValue: _modelValue, descriptionText: _descriptionText, ...rest } = args;
98
+ return rest;
99
+ });
100
+ const submitted = ref("");
101
+ const lastComplete = ref("");
102
+ return { args, componentArgs, submitted, lastComplete };
103
+ },
104
+ template: `
105
+ <form @submit.prevent="submitted = args.modelValue">
106
+ <InputOtpField v-model="args.modelValue" v-bind="componentArgs" @complete="lastComplete = $event">
107
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
108
+ </InputOtpField>
109
+ <p style="margin-block-start: 1.6rem; overflow-wrap: anywhere;">
110
+ Model: "{{ args.modelValue }}" · last complete: "{{ lastComplete }}" · submitted: "{{ submitted }}"
111
+ </p>
112
+ </form>
113
+ `,
114
+ });
115
+
116
+ export const Default: Story = { render };
117
+
118
+ export const WithError: Story = {
119
+ args: { modelValue: "482", fieldHasError: true, errorMessage: "That code is incorrect or has expired." },
120
+ render,
121
+ };
122
+
123
+ export const LocalisedLabels: Story = {
124
+ args: {
125
+ label: "Code de vérification",
126
+ digitLabel: "Chiffre {index} sur {length}",
127
+ descriptionText: "Saisissez le code à 6 chiffres envoyé par e-mail.",
128
+ },
129
+ render,
130
+ };
131
+
132
+ export const Underlined: Story = { args: { inputVariant: "underlined" }, render };
133
+
134
+ export const NarrowContainer: Story = {
135
+ args: { length: 8 },
136
+ render,
137
+ parameters: { initialCanvas: "mobileCanvas" },
138
+ };
139
+
140
+ export const StressTest: Story = {
141
+ name: "Stress Test (Worst-Case Data)",
142
+ args: {
143
+ length: 99,
144
+ modelValue: "0a9<b>1234567890123456789",
145
+ label:
146
+ "🔐 Bestätigungscodeeingabefeldbeschriftungmitsehrlangemunumgebrochenemwort https://example.com/a/very/long/unbroken/url/that/should/wrap/somewhere <script>alert(1)</script>",
147
+ descriptionText:
148
+ "رمز التحقق Wir haben Ihnen einen Bestätigungscode an Ihre hinterlegte E-Mail-Adresse geschickt; geben Sie ihn innerhalb von zehn Minuten ein. <b>not bold</b>",
149
+ digitLabel: "Ziffer {index} von {length} 🔢",
150
+ fieldHasError: true,
151
+ errorMessage: "Dieser BestätigungscodeistleiderabgelaufenodernichtkorrektbitteversuchenSieeserneut",
152
+ },
153
+ render,
154
+ parameters: {
155
+ initialCanvas: "mobileCanvas",
156
+ docs: {
157
+ description: {
158
+ story:
159
+ "Length 99 (clamped to 12), a model value with letters and markup (non-digits show as empty boxes, overflow is ignored), long unbroken German/URL/emoji/RTL/HTML-like copy. Check at every canvas width: the 12 boxes shrink to fit rather than overflow, the legend, description and error wrap, and the markup renders as text. Also set Length to 0 and 1.",
160
+ },
161
+ },
162
+ },
163
+ };