srcdev-nuxt-components 9.6.7 → 9.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +18 -1
  3. package/.claude/skills/component-inline-action-button.md +1 -1
  4. package/.claude/skills/component-local-style-override.md +1 -1
  5. package/.claude/skills/component-naming.md +13 -13
  6. package/.claude/skills/components/container-glow.md +2 -2
  7. package/.claude/skills/components/form-field.md +2 -2
  8. package/.claude/skills/components/form-wrapper.md +1 -1
  9. package/.claude/skills/components/glowing-border.md +1 -1
  10. package/.claude/skills/components/input-checkbox-radio.md +19 -3
  11. package/.claude/skills/components/input-checkbox.md +14 -3
  12. package/.claude/skills/components/input-description.md +7 -7
  13. package/.claude/skills/components/input-error.md +3 -3
  14. package/.claude/skills/components/input-label.md +7 -8
  15. package/.claude/skills/components/input-number.md +60 -29
  16. package/.claude/skills/components/input-radio.md +15 -1
  17. package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
  18. package/.claude/skills/components/input-select.md +153 -0
  19. package/.claude/skills/components/input-text.md +220 -0
  20. package/.claude/skills/components/input-textarea.md +147 -0
  21. package/.claude/skills/components/select-menu.md +4 -4
  22. package/.claude/skills/components/toggle-switch.md +143 -0
  23. package/.claude/skills/composable-zod-validation.md +1 -1
  24. package/.claude/skills/icon-sets.md +1 -1
  25. package/.claude/skills/icon-sizing.md +1 -1
  26. package/.claude/skills/index.md +10 -10
  27. package/.claude/skills/storybook-add-story.md +1 -1
  28. package/.claude/skills/testing-add-unit-test.md +17 -0
  29. package/.claude/skills/theming-component-token-pattern.md +8 -8
  30. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  31. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  32. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  33. package/.vscode/srcdev-component-input-select.code-snippets +20 -14
  34. package/.vscode/srcdev-component-input-text.code-snippets +46 -40
  35. package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
  36. package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
  37. package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
  38. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
  39. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
  40. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
  41. package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
  42. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
  43. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
  44. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
  45. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  46. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  47. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  48. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  49. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  50. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  51. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  52. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  53. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  54. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  55. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  56. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  57. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  58. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  59. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  60. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  61. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  62. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +7 -7
  63. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +6 -6
  64. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +50 -6
  65. package/app/components/05.forms/input-number/InputNumber.vue +39 -27
  66. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  67. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  68. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  69. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  70. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  71. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  72. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  73. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  74. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  75. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  76. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  77. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  78. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  79. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  80. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  81. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  82. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
  83. package/app/components/05.forms/input-select/InputSelect.vue +198 -0
  84. package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
  85. package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
  86. package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
  87. package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
  88. package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
  89. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
  90. package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
  91. package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
  92. package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
  93. package/app/components/05.forms/input-text/InputTextField.vue +168 -0
  94. package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
  95. package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
  96. package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
  97. package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
  98. package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
  99. package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
  100. package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
  101. package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
  102. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
  103. package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
  104. package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
  105. package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
  106. package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
  107. package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
  108. package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
  109. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
  110. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
  111. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +34 -36
  112. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
  113. package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
  114. package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
  115. package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
  116. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
  117. package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
  118. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
  119. package/package.json +1 -1
  120. package/.claude/skills/components/input-select-core.md +0 -151
  121. package/.claude/skills/components/input-text-core.md +0 -204
  122. package/.claude/skills/components/input-textarea-core.md +0 -154
  123. package/.claude/skills/components/toggle-switch-core.md +0 -169
  124. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  125. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  126. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
  127. package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
  128. package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
  129. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
  130. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
  131. package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
  132. package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
  133. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
  134. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
  135. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
  136. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
  137. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
  138. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
  139. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
  140. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
  141. package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
  142. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
  143. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
  144. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
  145. package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
  146. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
  147. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
  148. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
  149. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
  150. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
  151. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
  152. 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
- # InputTextareaCore — Consumer Styling Guide
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 `InputTextCore`'s token set (see its own CONSUMER-STYLING.md).
13
+ This shape mirrors `InputText`'s token set (see its own CONSUMER-STYLING.md).
14
+
15
+ ### Colour
11
16
 
12
- | Token | Falls back to | Controls |
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` variant only) |
17
- | `--input-textarea-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` variant only) |
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
- ## Input variant
44
+ ## State hooks
28
45
 
29
- `:input-variant="'normal' | 'outlined' | 'underlined'"` (default `"normal"`) — same prop as
30
- `InputTextCore`. Only `normal` (bordered box, with hover/focus outline) and `underlined`
31
- (bottom-border only) have CSS today; `outlined` has no styling in `InputTextCore` either (no
32
- `&.outlined` rule exists there), so passing it renders with no border/outline at all — a
33
- pre-existing gap in the type, not specific to `InputTextareaCore`.
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
- <InputTextareaCore id="message" name="message" style="--input-textarea-border: var(--gold-04);" />
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>