srcdev-nuxt-components 9.6.8 → 9.6.10
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/component-compliance-checklist.md +28 -1
- package/.claude/skills/component-inline-action-button.md +1 -1
- package/.claude/skills/component-local-style-override.md +1 -1
- package/.claude/skills/component-naming.md +17 -12
- package/.claude/skills/components/container-glow.md +2 -2
- package/.claude/skills/components/form-field.md +2 -2
- package/.claude/skills/components/form-wrapper.md +1 -1
- package/.claude/skills/components/glowing-border.md +1 -1
- package/.claude/skills/components/input-description.md +7 -7
- package/.claude/skills/components/input-error.md +3 -3
- package/.claude/skills/components/input-label.md +7 -8
- package/.claude/skills/components/input-number.md +17 -17
- package/.claude/skills/components/input-select.md +153 -0
- package/.claude/skills/components/input-text.md +220 -0
- package/.claude/skills/components/input-textarea.md +147 -0
- package/.claude/skills/components/select-menu.md +4 -4
- package/.claude/skills/components/toggle-switch.md +143 -0
- package/.claude/skills/composable-zod-validation.md +1 -1
- package/.claude/skills/css-nesting-conventions.md +22 -1
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/icon-sizing.md +1 -1
- package/.claude/skills/index.md +5 -5
- package/.claude/skills/storybook-add-story.md +1 -1
- package/.claude/skills/theming-component-token-pattern.md +8 -8
- package/.claude/skills/theming-form-geometry-tokens.md +1 -1
- package/.vscode/srcdev-component-input-select.code-snippets +20 -14
- package/.vscode/srcdev-component-input-text.code-snippets +46 -40
- package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
- package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
- package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
- package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
- package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +6 -6
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +7 -7
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +9 -5
- package/app/components/05.forms/input-number/InputNumber.vue +2 -2
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
- package/app/components/05.forms/input-select/InputSelect.vue +198 -0
- package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
- package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
- package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
- package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
- package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
- package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
- package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
- package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
- package/app/components/05.forms/input-text/InputTextField.vue +168 -0
- package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
- package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
- package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
- package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
- package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
- package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
- package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
- package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
- package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
- package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
- package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
- package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
- package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
- package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
- package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +30 -32
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
- package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
- package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
- package/package.json +1 -1
- package/.claude/skills/components/input-select-core.md +0 -151
- package/.claude/skills/components/input-text-core.md +0 -204
- package/.claude/skills/components/input-textarea-core.md +0 -154
- package/.claude/skills/components/toggle-switch-core.md +0 -169
- package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
- package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
- package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
- package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
- package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
- package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
- package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
- package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
- package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
- package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
- package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
- package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
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 "../InputTextAsNumberField.vue";
|
|
6
|
+
|
|
7
|
+
const initialPropsData = {
|
|
8
|
+
name: "quantity",
|
|
9
|
+
label: "Quantity",
|
|
10
|
+
errorMessage: "",
|
|
11
|
+
min: 0,
|
|
12
|
+
max: 10,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
16
|
+
const wrapperFactory = (propsData = {}) => {
|
|
17
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
18
|
+
|
|
19
|
+
return mountSuspended(ComponentUnderTest, {
|
|
20
|
+
attachTo: document.body,
|
|
21
|
+
props: mockPropsData,
|
|
22
|
+
slots: {
|
|
23
|
+
left: () => "−",
|
|
24
|
+
right: () => "+",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const stepButtons = () => {
|
|
30
|
+
const [stepDown, stepUp] = wrapper.findAll("button");
|
|
31
|
+
return { stepDown: stepDown!, stepUp: stepUp! };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const lastEmitted = () => wrapper.emitted("update:modelValue")?.at(-1);
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
wrapper?.unmount();
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("InputTextAsNumberField", () => {
|
|
42
|
+
it("mounts without error", async () => {
|
|
43
|
+
wrapper = await wrapperFactory();
|
|
44
|
+
expect(wrapper.vm).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders .input-text-as-number-field as the root", async () => {
|
|
48
|
+
wrapper = await wrapperFactory();
|
|
49
|
+
expect(wrapper.classes()).toContain("input-text-as-number-field");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("uses the default step button text", async () => {
|
|
53
|
+
wrapper = await wrapperFactory();
|
|
54
|
+
|
|
55
|
+
expect(stepButtons().stepDown.text()).toContain("Step down");
|
|
56
|
+
expect(stepButtons().stepUp.text()).toContain("Step up");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("uses custom stepDownText/stepUpText when provided", async () => {
|
|
60
|
+
wrapper = await wrapperFactory({ stepDownText: "Diminuer", stepUpText: "Augmenter" });
|
|
61
|
+
|
|
62
|
+
expect(stepButtons().stepDown.text()).toContain("Diminuer");
|
|
63
|
+
expect(stepButtons().stepUp.text()).toContain("Augmenter");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("increments the value when the step-up button is clicked", async () => {
|
|
67
|
+
wrapper = await wrapperFactory({ modelValue: 5 });
|
|
68
|
+
await stepButtons().stepUp.trigger("click");
|
|
69
|
+
expect(lastEmitted()).toEqual([6]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rounds fractional steps instead of drifting", async () => {
|
|
73
|
+
wrapper = await wrapperFactory({ modelValue: 0.2, max: 1, step: 0.1 });
|
|
74
|
+
await stepButtons().stepUp.trigger("click");
|
|
75
|
+
expect(lastEmitted()).toEqual([0.3]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clamps to max instead of overshooting", async () => {
|
|
79
|
+
wrapper = await wrapperFactory({ modelValue: 8, step: 5 });
|
|
80
|
+
await stepButtons().stepUp.trigger("click");
|
|
81
|
+
expect(lastEmitted()).toEqual([10]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("pulls a typed out-of-range value back into range", async () => {
|
|
85
|
+
wrapper = await wrapperFactory({ modelValue: 50 });
|
|
86
|
+
await stepButtons().stepDown.trigger("click");
|
|
87
|
+
expect(lastEmitted()).toEqual([10]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("starts from 0, clamped, when the field is empty", async () => {
|
|
91
|
+
wrapper = await wrapperFactory({ modelValue: undefined, min: 3 });
|
|
92
|
+
await stepButtons().stepUp.trigger("click");
|
|
93
|
+
expect(lastEmitted()).toEqual([3]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("makes the step buttons read-only at and beyond the limits", async () => {
|
|
97
|
+
wrapper = await wrapperFactory({ modelValue: 12 });
|
|
98
|
+
expect(stepButtons().stepUp.attributes("readonly")).toBeDefined();
|
|
99
|
+
|
|
100
|
+
await wrapper.setProps({ modelValue: 0 });
|
|
101
|
+
expect(stepButtons().stepDown.attributes("readonly")).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("parses typed text into a number and empties to undefined", async () => {
|
|
105
|
+
wrapper = await wrapperFactory({ modelValue: 1 });
|
|
106
|
+
const input = wrapper.find("input");
|
|
107
|
+
|
|
108
|
+
await input.setValue("7");
|
|
109
|
+
expect(lastEmitted()).toEqual([7]);
|
|
110
|
+
|
|
111
|
+
await input.setValue("");
|
|
112
|
+
expect(lastEmitted()).toEqual([undefined]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("picks inputmode from min and step", async () => {
|
|
116
|
+
wrapper = await wrapperFactory();
|
|
117
|
+
expect(wrapper.find("input").attributes("inputmode")).toBe("numeric");
|
|
118
|
+
|
|
119
|
+
await wrapper.setProps({ step: 0.5 });
|
|
120
|
+
expect(wrapper.find("input").attributes("inputmode")).toBe("decimal");
|
|
121
|
+
|
|
122
|
+
await wrapper.setProps({ min: -5 });
|
|
123
|
+
expect(wrapper.find("input").attributes("inputmode")).toBe("text");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("applies styleClassPassthrough to its root only and reacts to changes", async () => {
|
|
127
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-number-class"] });
|
|
128
|
+
expect(wrapper.classes()).toContain("custom-number-class");
|
|
129
|
+
expect(wrapper.find("input").classes()).not.toContain("custom-number-class");
|
|
130
|
+
|
|
131
|
+
await wrapper.setProps({ styleClassPassthrough: ["other-class"] });
|
|
132
|
+
await nextTick();
|
|
133
|
+
expect(wrapper.classes()).toContain("other-class");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import { defineComponent, nextTick, ref } from "vue";
|
|
3
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
4
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
5
|
+
import ComponentUnderTest from "../InputTextField.vue";
|
|
6
|
+
|
|
7
|
+
const initialPropsData = {
|
|
8
|
+
type: "text" as const,
|
|
9
|
+
name: "testName",
|
|
10
|
+
label: "Test label",
|
|
11
|
+
errorMessage: "",
|
|
12
|
+
modelValue: "",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
16
|
+
const wrapperFactory = (propsData = {}, slots = {}) => {
|
|
17
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
18
|
+
|
|
19
|
+
return mountSuspended(ComponentUnderTest, {
|
|
20
|
+
attachTo: document.body,
|
|
21
|
+
props: mockPropsData,
|
|
22
|
+
slots,
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
wrapper?.unmount();
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("InputTextField", () => {
|
|
32
|
+
it("mounts without error", async () => {
|
|
33
|
+
wrapper = await wrapperFactory();
|
|
34
|
+
expect(wrapper.vm).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders .input-text-field as the root", async () => {
|
|
38
|
+
wrapper = await wrapperFactory();
|
|
39
|
+
expect(wrapper.classes()).toContain("input-text-field");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders the label text, linked to the input", async () => {
|
|
43
|
+
wrapper = await wrapperFactory({ label: "Email address" });
|
|
44
|
+
const label = wrapper.find("label");
|
|
45
|
+
|
|
46
|
+
expect(label.text()).toContain("Email address");
|
|
47
|
+
expect(label.attributes("for")).toBe(wrapper.find("input").attributes("id"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not leak label or error-message onto the control as attributes", async () => {
|
|
51
|
+
wrapper = await wrapperFactory({ errorMessage: "Oops" });
|
|
52
|
+
const control = wrapper.find(".input-text-wrapper");
|
|
53
|
+
|
|
54
|
+
expect(control.attributes("label")).toBeUndefined();
|
|
55
|
+
expect(control.attributes("error-message")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("forwards min/max through to the input (e.g. for type=date)", async () => {
|
|
59
|
+
wrapper = await wrapperFactory({ type: "date", min: "2026-01-01", max: "2026-12-31" });
|
|
60
|
+
const input = wrapper.find("input");
|
|
61
|
+
|
|
62
|
+
expect(input.attributes("type")).toBe("date");
|
|
63
|
+
expect(input.attributes("min")).toBe("2026-01-01");
|
|
64
|
+
expect(input.attributes("max")).toBe("2026-12-31");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("forwards readonly and autocomplete to the input", async () => {
|
|
68
|
+
wrapper = await wrapperFactory({ readonly: true, autocomplete: "email" });
|
|
69
|
+
expect(wrapper.find("input").attributes("readonly")).toBeDefined();
|
|
70
|
+
expect(wrapper.find("input").attributes("autocomplete")).toBe("email");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("sets data-invalid on the root and links the error via aria-describedby", async () => {
|
|
74
|
+
wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "Please enter an email" });
|
|
75
|
+
|
|
76
|
+
expect(wrapper.attributes("data-invalid")).toBe("");
|
|
77
|
+
expect(wrapper.text()).toContain("Please enter an email");
|
|
78
|
+
expect(wrapper.find("input").attributes("aria-describedby")).toMatch(/-error-message$/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("marks the field dirty once it has a value", async () => {
|
|
82
|
+
wrapper = await wrapperFactory();
|
|
83
|
+
expect(wrapper.attributes("data-dirty")).toBeUndefined();
|
|
84
|
+
|
|
85
|
+
await wrapper.setProps({ modelValue: "x" });
|
|
86
|
+
await nextTick();
|
|
87
|
+
expect(wrapper.attributes("data-dirty")).toBe("");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("renders the description after the input for the outlined variant", async () => {
|
|
91
|
+
wrapper = await wrapperFactory({ inputVariant: "outlined" }, { descriptionText: () => "Hint text" });
|
|
92
|
+
const html = wrapper.html();
|
|
93
|
+
expect(html.indexOf("Hint text")).toBeGreaterThan(html.indexOf("<input"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("applies styleClassPassthrough to its root only and reacts to changes", async () => {
|
|
97
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: "first-class" });
|
|
98
|
+
expect(wrapper.classes()).toContain("first-class");
|
|
99
|
+
expect(wrapper.find("input").classes()).not.toContain("first-class");
|
|
100
|
+
|
|
101
|
+
await wrapper.setProps({ styleClassPassthrough: "second-class" });
|
|
102
|
+
await nextTick();
|
|
103
|
+
expect(wrapper.classes()).toContain("second-class");
|
|
104
|
+
expect(wrapper.classes()).not.toContain("first-class");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("points aria-describedby at the description once it appears after mount", async () => {
|
|
108
|
+
const Host = defineComponent({
|
|
109
|
+
components: { ComponentUnderTest },
|
|
110
|
+
setup() {
|
|
111
|
+
return { modelValue: ref(""), showDescription: ref(false) };
|
|
112
|
+
},
|
|
113
|
+
template: `
|
|
114
|
+
<ComponentUnderTest v-model="modelValue" type="text" name="email" label="Email" error-message="">
|
|
115
|
+
<template v-if="showDescription" #descriptionText>We never share it.</template>
|
|
116
|
+
</ComponentUnderTest>
|
|
117
|
+
`,
|
|
118
|
+
});
|
|
119
|
+
const host = await mountSuspended(Host);
|
|
120
|
+
const input = () => host.find("input");
|
|
121
|
+
expect(input().attributes("aria-describedby")).toBeFalsy();
|
|
122
|
+
(host.vm as unknown as { showDescription: boolean }).showDescription = true;
|
|
123
|
+
await nextTick();
|
|
124
|
+
expect(input().attributes("aria-describedby")).toMatch(/-description$/);
|
|
125
|
+
(host.vm as unknown as { showDescription: boolean }).showDescription = false;
|
|
126
|
+
await nextTick();
|
|
127
|
+
expect(input().attributes("aria-describedby")).toBeFalsy();
|
|
128
|
+
host.unmount();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# InputTextarea — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
> **Renamed 2026-10-16**: `InputTextareaCore` → `InputTextarea`, `InputTextareaWithLabel` →
|
|
4
|
+
> `InputTextareaField`. Class changes are listed under "State hooks" below.
|
|
2
5
|
|
|
3
6
|
## Public token API
|
|
4
7
|
|
|
@@ -7,30 +10,73 @@ name to override just this component; leave it unset and it inherits the shared
|
|
|
7
10
|
`--theme-*` token every other themed component also falls back to. See
|
|
8
11
|
`theming-component-token-pattern.md` for why this two-tier shape exists.
|
|
9
12
|
|
|
10
|
-
This shape mirrors `
|
|
13
|
+
This shape mirrors `InputText`'s token set (see its own CONSUMER-STYLING.md).
|
|
14
|
+
|
|
15
|
+
### Colour
|
|
11
16
|
|
|
12
|
-
| Token |
|
|
17
|
+
| Token | Default | Controls |
|
|
13
18
|
|---|---|---|
|
|
14
19
|
| `--input-textarea-surface` | `var(--theme-input-surface)` | Wrapper background |
|
|
15
20
|
| `--input-textarea-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variant border) |
|
|
16
|
-
| `--input-textarea-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover (`normal`
|
|
17
|
-
| `--input-textarea-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal`
|
|
21
|
+
| `--input-textarea-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover (`normal` only) |
|
|
22
|
+
| `--input-textarea-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` only) |
|
|
18
23
|
| `--input-textarea-text-color` | `var(--theme-input-text-color-normal)` | Textarea text colour |
|
|
19
24
|
| `--input-textarea-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
|
|
20
25
|
|
|
26
|
+
### Size
|
|
27
|
+
|
|
28
|
+
| Token | Default | Controls |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `--input-textarea-min-height` | `6lh` | Minimum height (six lines) |
|
|
31
|
+
| `--input-textarea-max-height` | `none` | Maximum height before the textarea scrolls. It grows with its content up to here where `field-sizing: content` is supported |
|
|
32
|
+
| `--input-textarea-slot-gap` | `1.2rem` | Gap between the left/right slots and the textarea |
|
|
33
|
+
| `--input-textarea-slot-align` | `center` | Vertical position of the left/right slots against the textarea (`align-self`: `start`, `center`, `end`) |
|
|
34
|
+
| `--input-textarea-slot-padding-block` | `0` | Space above and below each slot, so a `start`- or `end`-aligned slot doesn't sit against the field's edge |
|
|
35
|
+
|
|
21
36
|
Geometry (`--form-textarea-padding-block`, `--form-textarea-padding-inline`, `--input-font-size`,
|
|
22
37
|
`--input-textarea-line-height`, `--input-padding-inline`, etc.) is shared across every `05.forms`
|
|
23
38
|
component and untouched by this — see `theming-form-geometry-tokens.md`.
|
|
24
39
|
|
|
40
|
+
Private (not public API): `--_border`, resolved once and read by both border variants.
|
|
41
|
+
|
|
25
42
|
---
|
|
26
43
|
|
|
27
|
-
##
|
|
44
|
+
## State hooks
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
| Attribute | On | When |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-textarea-wrapper`, `.input-textarea-field` | Always, mirrors `input-variant` |
|
|
49
|
+
| `data-theme` | `.input-textarea-wrapper`, `.input-textarea-field` | Always, mirrors `theme` |
|
|
50
|
+
| `data-invalid` | `.input-textarea-wrapper`, `.input-textarea-field` | `field-has-error` |
|
|
51
|
+
| `data-dirty` | `.input-textarea-wrapper`, `.input-textarea-field` | The Field has seen a non-empty value |
|
|
52
|
+
| `data-active` | `.input-textarea-wrapper`, `.input-textarea-field` | The textarea has focus |
|
|
53
|
+
| `data-has-left-slot` / `data-has-right-slot` | `.input-textarea-wrapper` | The `left` / `right` slot is filled |
|
|
54
|
+
|
|
55
|
+
Inner elements: `.input-textarea` (the native textarea), `.input-textarea-slot` (both slot
|
|
56
|
+
holders), `.input-textarea-slot-left`, `.input-textarea-slot-right`, `.input-textarea-label` (the
|
|
57
|
+
Field's `InputLabel`), `.input-text-description` (the Field's `InputDescription`, shared with the
|
|
58
|
+
other Field components).
|
|
59
|
+
|
|
60
|
+
`outlined` has no CSS of its own: the control renders with no border or outline. In the Field it
|
|
61
|
+
moves the description after the control and error.
|
|
62
|
+
|
|
63
|
+
> **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
|
|
64
|
+
> (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
|
|
65
|
+
> above instead:
|
|
66
|
+
>
|
|
67
|
+
> - `.input-textarea-wrapper.normal` / `.underlined` / `.outlined` → `[data-input-variant="…"]`
|
|
68
|
+
> - `.input-textarea-wrapper.dirty` / `.active` / `.error` → `[data-dirty]` / `[data-active]` /
|
|
69
|
+
> `[data-invalid]`; `.has-left-slot` / `.has-right-slot` → `[data-has-left-slot]` /
|
|
70
|
+
> `[data-has-right-slot]`
|
|
71
|
+
> - `.slot.left-slot` / `.slot.right-slot` → `.input-textarea-slot-left` /
|
|
72
|
+
> `.input-textarea-slot-right`
|
|
73
|
+
> - `.input-textarea-core` → `.input-textarea` (no longer carries `dirty`/`active` classes)
|
|
74
|
+
> - `.input-textarea-with-label` → `.input-textarea-field`, now the Field's root (it used to sit
|
|
75
|
+
> inside an unclassed `<div>`); its variant/`dirty`/`active` classes → the attributes above
|
|
76
|
+
>
|
|
77
|
+
> Also: the textarea keeps typed line breaks and wraps long unbroken strings (`white-space:
|
|
78
|
+
> pre-wrap`, `overflow-wrap: anywhere`), and the Field wraps long label, description and error
|
|
79
|
+
> copy.
|
|
34
80
|
|
|
35
81
|
---
|
|
36
82
|
|
|
@@ -40,6 +86,7 @@ pre-existing gap in the type, not specific to `InputTextareaCore`.
|
|
|
40
86
|
:where(html) {
|
|
41
87
|
--input-textarea-surface: var(--rose-09);
|
|
42
88
|
--input-textarea-border: var(--rose-05);
|
|
89
|
+
--input-textarea-max-height: 20lh;
|
|
43
90
|
}
|
|
44
91
|
```
|
|
45
92
|
|
|
@@ -48,6 +95,20 @@ Only declare the tokens you want to change — everything else keeps inheriting
|
|
|
48
95
|
|
|
49
96
|
---
|
|
50
97
|
|
|
98
|
+
## Recipe: slot icon on the first line
|
|
99
|
+
|
|
100
|
+
Top-align the slots and pad them by the textarea's own block padding, so an icon lines up with the
|
|
101
|
+
first line of text instead of the middle of a tall field:
|
|
102
|
+
|
|
103
|
+
```css
|
|
104
|
+
.contact-form {
|
|
105
|
+
--input-textarea-slot-align: start;
|
|
106
|
+
--input-textarea-slot-padding-block: var(--form-textarea-padding-block);
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
51
112
|
## Local overrides
|
|
52
113
|
|
|
53
114
|
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
@@ -56,9 +117,30 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
56
117
|
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
57
118
|
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
58
119
|
|
|
120
|
+
**Caveat**: `InputTextarea`'s own passthrough lands on the `<textarea>`, which has no descendants,
|
|
121
|
+
so tokens set through it only reach the textarea itself (text, placeholder colour and the height
|
|
122
|
+
tokens). Set the others on the Field root or an ancestor.
|
|
123
|
+
|
|
59
124
|
### One instance
|
|
60
125
|
|
|
61
126
|
```vue
|
|
62
|
-
<
|
|
127
|
+
<InputTextareaField
|
|
128
|
+
v-model="message"
|
|
129
|
+
name="message"
|
|
130
|
+
label="Message"
|
|
131
|
+
error-message=""
|
|
132
|
+
style="--input-textarea-border: var(--gold-04);"
|
|
133
|
+
/>
|
|
63
134
|
```
|
|
64
135
|
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Class passthrough
|
|
139
|
+
|
|
140
|
+
`InputTextarea`'s `:style-class-passthrough` lands on the native
|
|
141
|
+
`<textarea class="input-textarea">`. `InputTextareaField`'s lands on its root
|
|
142
|
+
`.input-textarea-field` only.
|
|
143
|
+
|
|
144
|
+
> **Changed 2026-10-16**: `InputTextareaWithLabel` applied its passthrough classes to both its
|
|
145
|
+
> root and the inner textarea. If you styled the textarea that way, select
|
|
146
|
+
> `.your-class .input-textarea` instead.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="input-textarea-wrapper"
|
|
4
|
+
:data-theme="theme"
|
|
5
|
+
:data-input-variant="inputVariant"
|
|
6
|
+
:data-invalid="fieldHasError ? '' : null"
|
|
7
|
+
:data-dirty="isDirty ? '' : null"
|
|
8
|
+
:data-active="isActive ? '' : null"
|
|
9
|
+
:data-has-left-slot="slots.left ? '' : null"
|
|
10
|
+
:data-has-right-slot="slots.right ? '' : null"
|
|
11
|
+
>
|
|
12
|
+
<span v-if="slots.left" class="input-textarea-slot input-textarea-slot-left">
|
|
13
|
+
<slot name="left"></slot>
|
|
14
|
+
</span>
|
|
15
|
+
|
|
16
|
+
<textarea
|
|
17
|
+
:id
|
|
18
|
+
v-model="modelValue"
|
|
19
|
+
:maxlength
|
|
20
|
+
:placeholder
|
|
21
|
+
:name
|
|
22
|
+
:required
|
|
23
|
+
:readonly
|
|
24
|
+
:autocomplete
|
|
25
|
+
:class="['input-textarea', elementClasses]"
|
|
26
|
+
:aria-invalid="fieldHasError ? 'true' : undefined"
|
|
27
|
+
:aria-describedby="ariaDescribedby || undefined"
|
|
28
|
+
@focusin="isActive = true"
|
|
29
|
+
@focusout="isActive = false"
|
|
30
|
+
></textarea>
|
|
31
|
+
|
|
32
|
+
<span v-if="slots.right" class="input-textarea-slot input-textarea-slot-right">
|
|
33
|
+
<slot name="right"></slot>
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
|
|
40
|
+
|
|
41
|
+
interface Props {
|
|
42
|
+
maxlength?: number;
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
ariaDescribedby?: string;
|
|
47
|
+
fieldHasError?: boolean;
|
|
48
|
+
required?: boolean;
|
|
49
|
+
readonly?: boolean;
|
|
50
|
+
autocomplete?: string;
|
|
51
|
+
styleClassPassthrough?: string | string[];
|
|
52
|
+
theme?: FormUiTheme;
|
|
53
|
+
inputVariant?: InputUiVariant;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
57
|
+
maxlength: 255,
|
|
58
|
+
placeholder: "",
|
|
59
|
+
ariaDescribedby: "",
|
|
60
|
+
fieldHasError: false,
|
|
61
|
+
required: false,
|
|
62
|
+
readonly: false,
|
|
63
|
+
autocomplete: undefined,
|
|
64
|
+
styleClassPassthrough: () => [],
|
|
65
|
+
theme: "default",
|
|
66
|
+
inputVariant: "normal",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const slots = useSlots();
|
|
70
|
+
|
|
71
|
+
const modelValue = defineModel<string | number | readonly string[] | null | undefined>({
|
|
72
|
+
required: true,
|
|
73
|
+
});
|
|
74
|
+
const isDirty = defineModel<boolean>("isDirty");
|
|
75
|
+
const isActive = defineModel<boolean>("isActive");
|
|
76
|
+
|
|
77
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
78
|
+
|
|
79
|
+
watch(
|
|
80
|
+
() => props.styleClassPassthrough,
|
|
81
|
+
() => {
|
|
82
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<style lang="css">
|
|
88
|
+
@layer components {
|
|
89
|
+
.input-textarea-wrapper {
|
|
90
|
+
--_border: var(--input-textarea-border, var(--theme-border));
|
|
91
|
+
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: var(--input-textarea-slot-gap, 1.2rem);
|
|
95
|
+
min-inline-size: 0;
|
|
96
|
+
max-inline-size: 100%;
|
|
97
|
+
background-color: var(--input-textarea-surface, var(--theme-input-surface));
|
|
98
|
+
padding-inline: var(--input-padding-inline);
|
|
99
|
+
transition: all var(--theme-form-transition-duration) ease-in-out;
|
|
100
|
+
|
|
101
|
+
z-index: 2;
|
|
102
|
+
position: relative;
|
|
103
|
+
|
|
104
|
+
&[data-input-variant="normal"] {
|
|
105
|
+
border: var(--form-element-border-width) solid var(--_border);
|
|
106
|
+
border-radius: var(--form-input-border-radius);
|
|
107
|
+
outline: var(--form-element-outline-width) solid transparent;
|
|
108
|
+
|
|
109
|
+
&:has(textarea:is(:hover)) {
|
|
110
|
+
outline: var(--form-element-outline-width-focus) solid
|
|
111
|
+
var(--input-textarea-border-hover, var(--theme-border-focus));
|
|
112
|
+
outline-offset: var(--form-element-outline-offset-focus);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
&:has(textarea:focus-visible) {
|
|
116
|
+
outline: var(--form-element-outline-width-focus) solid
|
|
117
|
+
var(--input-textarea-border-focus, var(--theme-border-focus));
|
|
118
|
+
outline-offset: var(--form-element-outline-offset-focus);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&[data-input-variant="underlined"] {
|
|
123
|
+
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.input-textarea-slot {
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-shrink: 0;
|
|
129
|
+
align-self: var(--input-textarea-slot-align, center);
|
|
130
|
+
padding-block: var(--input-textarea-slot-padding-block, 0);
|
|
131
|
+
place-items: center;
|
|
132
|
+
background-clip: padding-box;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.input-textarea {
|
|
136
|
+
all: unset;
|
|
137
|
+
touch-action: manipulation;
|
|
138
|
+
flex-grow: 1;
|
|
139
|
+
min-inline-size: 0;
|
|
140
|
+
field-sizing: content;
|
|
141
|
+
white-space: pre-wrap;
|
|
142
|
+
overflow-wrap: anywhere;
|
|
143
|
+
overflow-y: auto;
|
|
144
|
+
|
|
145
|
+
color: var(--input-textarea-text-color, var(--theme-input-text-color-normal));
|
|
146
|
+
font-family: var(--font-family);
|
|
147
|
+
font-size: var(--input-font-size);
|
|
148
|
+
line-height: var(--input-textarea-line-height);
|
|
149
|
+
padding-block: var(--form-textarea-padding-block);
|
|
150
|
+
padding-inline: var(--form-textarea-padding-inline);
|
|
151
|
+
|
|
152
|
+
min-height: var(--input-textarea-min-height, 6lh);
|
|
153
|
+
max-block-size: var(--input-textarea-max-height, none);
|
|
154
|
+
|
|
155
|
+
&::placeholder {
|
|
156
|
+
color: var(--input-textarea-placeholder-color, var(--theme-input-placeholder));
|
|
157
|
+
font-size: var(--theme-input-placeholder-font-size);
|
|
158
|
+
font-style: italic;
|
|
159
|
+
line-height: 1;
|
|
160
|
+
font-weight: normal;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
</style>
|