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.
- 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/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/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/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/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-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/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/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/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/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/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap
ADDED
|
@@ -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 };
|