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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { nextTick } from "vue";
|
|
3
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
4
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
5
|
+
import ComponentUnderTest from "../InputOtp.vue";
|
|
6
|
+
|
|
7
|
+
const wrapperFactory = async (props: Record<string, unknown> = {}) => {
|
|
8
|
+
const state = { value: (props.modelValue as string) ?? "" };
|
|
9
|
+
const wrapper = await mountSuspended(ComponentUnderTest, {
|
|
10
|
+
attachTo: document.body,
|
|
11
|
+
props: {
|
|
12
|
+
id: "code",
|
|
13
|
+
modelValue: state.value,
|
|
14
|
+
"onUpdate:modelValue": (value: string) => {
|
|
15
|
+
state.value = value;
|
|
16
|
+
wrapper.setProps({ modelValue: value });
|
|
17
|
+
},
|
|
18
|
+
...props,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
return { wrapper, state };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const boxes = (wrapper: VueWrapper) => wrapper.findAll<HTMLInputElement>(".input-otp-box");
|
|
25
|
+
|
|
26
|
+
const typeInto = async (wrapper: VueWrapper, index: number, value: string) => {
|
|
27
|
+
const box = boxes(wrapper)[index]!;
|
|
28
|
+
box.element.value = value;
|
|
29
|
+
await box.trigger("input");
|
|
30
|
+
await nextTick();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const paste = async (wrapper: VueWrapper, index: number, text: string) => {
|
|
34
|
+
const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
|
|
35
|
+
Object.defineProperty(event, "clipboardData", { value: { getData: () => text } });
|
|
36
|
+
boxes(wrapper)[index]!.element.dispatchEvent(event);
|
|
37
|
+
await nextTick();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("InputOtp", () => {
|
|
41
|
+
it("mounts without error", async () => {
|
|
42
|
+
const { wrapper } = await wrapperFactory();
|
|
43
|
+
expect(wrapper.vm).toBeTruthy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders one box per length, defaulting to six", async () => {
|
|
47
|
+
const { wrapper } = await wrapperFactory();
|
|
48
|
+
expect(boxes(wrapper)).toHaveLength(6);
|
|
49
|
+
|
|
50
|
+
await wrapper.setProps({ length: 4 });
|
|
51
|
+
expect(boxes(wrapper)).toHaveLength(4);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("clamps length between 1 and 12", async () => {
|
|
55
|
+
const { wrapper } = await wrapperFactory({ length: 99 });
|
|
56
|
+
expect(boxes(wrapper)).toHaveLength(12);
|
|
57
|
+
|
|
58
|
+
await wrapper.setProps({ length: 0 });
|
|
59
|
+
expect(boxes(wrapper)).toHaveLength(1);
|
|
60
|
+
|
|
61
|
+
await wrapper.setProps({ length: -3 });
|
|
62
|
+
expect(boxes(wrapper)).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("uses text inputs with a numeric keypad", async () => {
|
|
66
|
+
const { wrapper } = await wrapperFactory();
|
|
67
|
+
const box = boxes(wrapper)[0]!;
|
|
68
|
+
expect(box.attributes("type")).toBe("text");
|
|
69
|
+
expect(box.attributes("inputmode")).toBe("numeric");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects non-digit input", async () => {
|
|
73
|
+
const { wrapper, state } = await wrapperFactory();
|
|
74
|
+
await typeInto(wrapper, 0, "a");
|
|
75
|
+
expect(state.value).toBe("");
|
|
76
|
+
expect(boxes(wrapper)[0]!.element.value).toBe("");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("advances focus to the next box after a digit", async () => {
|
|
80
|
+
const { wrapper, state } = await wrapperFactory();
|
|
81
|
+
await typeInto(wrapper, 0, "4");
|
|
82
|
+
expect(state.value).toBe("4");
|
|
83
|
+
expect(document.activeElement).toBe(boxes(wrapper)[1]!.element);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("keeps a leading zero", async () => {
|
|
87
|
+
const { wrapper, state } = await wrapperFactory();
|
|
88
|
+
await typeInto(wrapper, 0, "0");
|
|
89
|
+
await typeInto(wrapper, 1, "1");
|
|
90
|
+
expect(state.value).toBe("01");
|
|
91
|
+
|
|
92
|
+
await paste(wrapper, 0, "012345");
|
|
93
|
+
expect(state.value).toBe("012345");
|
|
94
|
+
expect(boxes(wrapper)[0]!.element.value).toBe("0");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("replaces a digit when typing into a filled box", async () => {
|
|
98
|
+
const { wrapper, state } = await wrapperFactory({ modelValue: "123" });
|
|
99
|
+
await typeInto(wrapper, 1, "29");
|
|
100
|
+
expect(state.value).toBe("193");
|
|
101
|
+
|
|
102
|
+
await typeInto(wrapper, 0, "71");
|
|
103
|
+
expect(state.value).toBe("793");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("Backspace in an empty box clears the previous box and moves back", async () => {
|
|
107
|
+
const { wrapper, state } = await wrapperFactory({ modelValue: "12" });
|
|
108
|
+
await boxes(wrapper)[2]!.trigger("keydown", { key: "Backspace" });
|
|
109
|
+
await nextTick();
|
|
110
|
+
expect(state.value).toBe("1");
|
|
111
|
+
expect(document.activeElement).toBe(boxes(wrapper)[1]!.element);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("Backspace in a filled box only clears that box", async () => {
|
|
115
|
+
const { wrapper, state } = await wrapperFactory({ modelValue: "12" });
|
|
116
|
+
boxes(wrapper)[1]!.element.focus();
|
|
117
|
+
await boxes(wrapper)[1]!.trigger("keydown", { key: "Backspace" });
|
|
118
|
+
await typeInto(wrapper, 1, "");
|
|
119
|
+
expect(state.value).toBe("1");
|
|
120
|
+
expect(document.activeElement).toBe(boxes(wrapper)[1]!.element);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("paste spreads digits, strips non-digits and truncates to length", async () => {
|
|
124
|
+
const { wrapper, state } = await wrapperFactory();
|
|
125
|
+
await paste(wrapper, 0, "12-34 56789");
|
|
126
|
+
expect(state.value).toBe("123456");
|
|
127
|
+
expect(document.activeElement).toBe(boxes(wrapper)[5]!.element);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("a partial paste fills from the focused box", async () => {
|
|
131
|
+
const { wrapper, state } = await wrapperFactory({ modelValue: "12" });
|
|
132
|
+
await paste(wrapper, 2, "34");
|
|
133
|
+
expect(state.value).toBe("1234");
|
|
134
|
+
expect(document.activeElement).toBe(boxes(wrapper)[3]!.element);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("a paste with no digits changes nothing", async () => {
|
|
138
|
+
const { wrapper, state } = await wrapperFactory({ modelValue: "12" });
|
|
139
|
+
await paste(wrapper, 0, "abc");
|
|
140
|
+
expect(state.value).toBe("12");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("spreads a multi-digit autofill in one box across the boxes", async () => {
|
|
144
|
+
const { wrapper, state } = await wrapperFactory();
|
|
145
|
+
expect(boxes(wrapper)[0]!.attributes("autocomplete")).toBe("one-time-code");
|
|
146
|
+
expect(boxes(wrapper)[1]!.attributes("autocomplete")).toBe("off");
|
|
147
|
+
|
|
148
|
+
await typeInto(wrapper, 0, "654321");
|
|
149
|
+
expect(state.value).toBe("654321");
|
|
150
|
+
expect(boxes(wrapper)[0]!.element.value).toBe("6");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("Enter submits the parent form", async () => {
|
|
154
|
+
const form = document.createElement("form");
|
|
155
|
+
document.body.appendChild(form);
|
|
156
|
+
const requestSubmit = vi.spyOn(form, "requestSubmit").mockImplementation(() => {});
|
|
157
|
+
|
|
158
|
+
const wrapper = await mountSuspended(ComponentUnderTest, {
|
|
159
|
+
attachTo: form,
|
|
160
|
+
props: { id: "code", modelValue: "" },
|
|
161
|
+
});
|
|
162
|
+
await wrapper.findAll(".input-otp-box")[0]!.trigger("keydown", { key: "Enter" });
|
|
163
|
+
expect(requestSubmit).toHaveBeenCalledOnce();
|
|
164
|
+
form.remove();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("arrow keys, Home and End move between boxes", async () => {
|
|
168
|
+
const { wrapper } = await wrapperFactory();
|
|
169
|
+
const [first, second] = boxes(wrapper);
|
|
170
|
+
|
|
171
|
+
await first!.trigger("keydown", { key: "ArrowRight" });
|
|
172
|
+
expect(document.activeElement).toBe(second!.element);
|
|
173
|
+
|
|
174
|
+
await second!.trigger("keydown", { key: "ArrowLeft" });
|
|
175
|
+
expect(document.activeElement).toBe(first!.element);
|
|
176
|
+
|
|
177
|
+
await first!.trigger("keydown", { key: "End" });
|
|
178
|
+
expect(document.activeElement).toBe(boxes(wrapper)[5]!.element);
|
|
179
|
+
|
|
180
|
+
await boxes(wrapper)[5]!.trigger("keydown", { key: "Home" });
|
|
181
|
+
expect(document.activeElement).toBe(first!.element);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("selects the digit on focus so typing replaces it", async () => {
|
|
185
|
+
const { wrapper } = await wrapperFactory({ modelValue: "5" });
|
|
186
|
+
const box = boxes(wrapper)[0]!.element;
|
|
187
|
+
const select = vi.spyOn(box, "select");
|
|
188
|
+
box.focus();
|
|
189
|
+
expect(select).toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("does not take focus on mount by default", async () => {
|
|
193
|
+
const { wrapper } = await wrapperFactory();
|
|
194
|
+
await nextTick();
|
|
195
|
+
expect(document.activeElement).not.toBe(boxes(wrapper)[0]!.element);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("focuses the first box on mount when autofocus is set", async () => {
|
|
199
|
+
const { wrapper } = await wrapperFactory({ autofocus: true });
|
|
200
|
+
await nextTick();
|
|
201
|
+
expect(document.activeElement).toBe(boxes(wrapper)[0]!.element);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("focuses the first box when an error appears", async () => {
|
|
205
|
+
const { wrapper } = await wrapperFactory();
|
|
206
|
+
await wrapper.setProps({ fieldHasError: true });
|
|
207
|
+
await nextTick();
|
|
208
|
+
expect(document.activeElement).toBe(boxes(wrapper)[0]!.element);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("emits complete once every box is filled", async () => {
|
|
212
|
+
const { wrapper } = await wrapperFactory({ length: 3, modelValue: "12" });
|
|
213
|
+
expect(wrapper.emitted("complete")).toBeUndefined();
|
|
214
|
+
|
|
215
|
+
await typeInto(wrapper, 2, "3");
|
|
216
|
+
expect(wrapper.emitted("complete")).toEqual([["123"]]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("labels each box from digitLabel", async () => {
|
|
220
|
+
const { wrapper } = await wrapperFactory({ length: 4, digitLabel: "Ziffer {index} von {length}" });
|
|
221
|
+
const labels = wrapper.findAll("label");
|
|
222
|
+
expect(labels[0]!.text()).toBe("Ziffer 1 von 4");
|
|
223
|
+
expect(labels[0]!.attributes("for")).toBe("code-0");
|
|
224
|
+
expect(labels[3]!.text()).toBe("Ziffer 4 von 4");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("exposes a named group only when groupLabel is set", async () => {
|
|
228
|
+
const { wrapper } = await wrapperFactory();
|
|
229
|
+
expect(wrapper.find(".input-otp").attributes("role")).toBeUndefined();
|
|
230
|
+
|
|
231
|
+
await wrapper.setProps({ groupLabel: "Verification code" });
|
|
232
|
+
expect(wrapper.find(".input-otp").attributes("role")).toBe("group");
|
|
233
|
+
expect(wrapper.find(".input-otp").attributes("aria-label")).toBe("Verification code");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("marks every box invalid and described when there is an error", async () => {
|
|
237
|
+
const { wrapper } = await wrapperFactory({ fieldHasError: true, ariaDescribedby: "code-error" });
|
|
238
|
+
expect(wrapper.find(".input-otp").attributes("data-invalid")).toBe("");
|
|
239
|
+
boxes(wrapper).forEach((box) => {
|
|
240
|
+
expect(box.attributes("aria-invalid")).toBe("true");
|
|
241
|
+
expect(box.attributes("aria-describedby")).toBe("code-error");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("holds the full code in a hidden input when name is set", async () => {
|
|
246
|
+
const { wrapper } = await wrapperFactory({ modelValue: "0123" });
|
|
247
|
+
expect(wrapper.find('input[type="hidden"]').exists()).toBe(false);
|
|
248
|
+
|
|
249
|
+
await wrapper.setProps({ name: "code" });
|
|
250
|
+
const hidden = wrapper.find<HTMLInputElement>('input[type="hidden"]');
|
|
251
|
+
expect(hidden.attributes("name")).toBe("code");
|
|
252
|
+
expect(hidden.element.value).toBe("0123");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("ignores non-digits and overflow in the model value", async () => {
|
|
256
|
+
const { wrapper } = await wrapperFactory({ length: 3, modelValue: "1a34567" });
|
|
257
|
+
expect(boxes(wrapper).map((box) => box.element.value)).toEqual(["1", "", "3"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("sets native required on every box when required", async () => {
|
|
261
|
+
const { wrapper } = await wrapperFactory({ required: true, length: 2 });
|
|
262
|
+
boxes(wrapper).forEach((box) => expect(box.attributes("required")).toBeDefined());
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("applies theme, variant and passthrough classes", async () => {
|
|
266
|
+
const { wrapper } = await wrapperFactory({
|
|
267
|
+
theme: "success",
|
|
268
|
+
inputVariant: "underlined",
|
|
269
|
+
styleClassPassthrough: ["extra"],
|
|
270
|
+
});
|
|
271
|
+
const root = wrapper.find(".input-otp");
|
|
272
|
+
expect(root.attributes("data-theme")).toBe("success");
|
|
273
|
+
expect(root.classes()).toEqual(expect.arrayContaining(["underlined", "extra"]));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { nextTick } from "vue";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ComponentUnderTest from "../InputOtpField.vue";
|
|
5
|
+
|
|
6
|
+
const wrapperFactory = (props: Record<string, unknown> = {}, slots = {}) =>
|
|
7
|
+
mountSuspended(ComponentUnderTest, {
|
|
8
|
+
attachTo: document.body,
|
|
9
|
+
props: { name: "code", label: "Verification code", modelValue: "", ...props },
|
|
10
|
+
slots,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("InputOtpField", () => {
|
|
14
|
+
it("mounts without error", async () => {
|
|
15
|
+
const wrapper = await wrapperFactory();
|
|
16
|
+
expect(wrapper.vm).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("names the group with the label as a fieldset legend", async () => {
|
|
20
|
+
const wrapper = await wrapperFactory();
|
|
21
|
+
expect(wrapper.find("fieldset.input-otp-field").exists()).toBe(true);
|
|
22
|
+
expect(wrapper.find("legend").text()).toBe("Verification code");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders the boxes and a hidden input carrying the code", async () => {
|
|
26
|
+
const wrapper = await wrapperFactory({ length: 4, modelValue: "0042" });
|
|
27
|
+
expect(wrapper.findAll(".input-otp-box")).toHaveLength(4);
|
|
28
|
+
const hidden = wrapper.find<HTMLInputElement>('input[type="hidden"]');
|
|
29
|
+
expect(hidden.attributes("name")).toBe("code");
|
|
30
|
+
expect(hidden.element.value).toBe("0042");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("links the error message to every box when there is an error", async () => {
|
|
34
|
+
const wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "That code has expired" });
|
|
35
|
+
expect(wrapper.text()).toContain("That code has expired");
|
|
36
|
+
expect(wrapper.find("fieldset").attributes("data-invalid")).toBe("");
|
|
37
|
+
|
|
38
|
+
const errorId = wrapper.find(".input-error-message").attributes("id");
|
|
39
|
+
wrapper.findAll(".input-otp-box").forEach((box) => {
|
|
40
|
+
expect(box.attributes("aria-describedby")).toBe(errorId);
|
|
41
|
+
expect(box.attributes("aria-invalid")).toBe("true");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("links the description when a description slot is used", async () => {
|
|
46
|
+
const wrapper = await wrapperFactory({}, { descriptionText: "We sent it to your email" });
|
|
47
|
+
expect(wrapper.text()).toContain("We sent it to your email");
|
|
48
|
+
const describedBy = wrapper.find(".input-otp-box").attributes("aria-describedby");
|
|
49
|
+
expect(describedBy).toMatch(/-description$/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("forwards digitLabel to the box labels", async () => {
|
|
53
|
+
const wrapper = await wrapperFactory({ length: 2, digitLabel: "Chiffre {index} sur {length}" });
|
|
54
|
+
expect(wrapper.findAll("label")[0]!.text()).toBe("Chiffre 1 sur 2");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("re-emits complete from the control", async () => {
|
|
58
|
+
const wrapper = await wrapperFactory({ length: 2, modelValue: "1" });
|
|
59
|
+
const box = wrapper.findAll<HTMLInputElement>(".input-otp-box")[1]!;
|
|
60
|
+
box.element.value = "2";
|
|
61
|
+
await box.trigger("input");
|
|
62
|
+
await nextTick();
|
|
63
|
+
expect(wrapper.emitted("complete")).toEqual([["12"]]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("focuses the first box on mount when autofocus is set", async () => {
|
|
67
|
+
const wrapper = await wrapperFactory({ autofocus: true });
|
|
68
|
+
await nextTick();
|
|
69
|
+
expect(document.activeElement).toBe(wrapper.find(".input-otp-box").element);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("applies passthrough classes to the fieldset", async () => {
|
|
73
|
+
const wrapper = await wrapperFactory({ styleClassPassthrough: ["extra"] });
|
|
74
|
+
expect(wrapper.find("fieldset").classes()).toContain("extra");
|
|
75
|
+
});
|
|
76
|
+
});
|