srcdev-nuxt-components 9.6.8 → 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 (108) hide show
  1. package/.claude/skills/component-compliance-checklist.md +12 -1
  2. package/.claude/skills/component-inline-action-button.md +1 -1
  3. package/.claude/skills/component-local-style-override.md +1 -1
  4. package/.claude/skills/component-naming.md +11 -11
  5. package/.claude/skills/components/container-glow.md +2 -2
  6. package/.claude/skills/components/form-field.md +2 -2
  7. package/.claude/skills/components/form-wrapper.md +1 -1
  8. package/.claude/skills/components/glowing-border.md +1 -1
  9. package/.claude/skills/components/input-description.md +7 -7
  10. package/.claude/skills/components/input-error.md +3 -3
  11. package/.claude/skills/components/input-label.md +7 -8
  12. package/.claude/skills/components/input-number.md +17 -17
  13. package/.claude/skills/components/input-select.md +153 -0
  14. package/.claude/skills/components/input-text.md +220 -0
  15. package/.claude/skills/components/input-textarea.md +147 -0
  16. package/.claude/skills/components/select-menu.md +4 -4
  17. package/.claude/skills/components/toggle-switch.md +143 -0
  18. package/.claude/skills/composable-zod-validation.md +1 -1
  19. package/.claude/skills/icon-sets.md +1 -1
  20. package/.claude/skills/icon-sizing.md +1 -1
  21. package/.claude/skills/index.md +5 -5
  22. package/.claude/skills/storybook-add-story.md +1 -1
  23. package/.claude/skills/theming-component-token-pattern.md +8 -8
  24. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  25. package/.vscode/srcdev-component-input-select.code-snippets +20 -14
  26. package/.vscode/srcdev-component-input-text.code-snippets +46 -40
  27. package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
  28. package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
  29. package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
  30. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
  31. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
  32. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
  33. package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
  34. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
  35. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
  36. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
  37. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +6 -6
  38. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +5 -5
  39. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +9 -5
  40. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  41. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
  42. package/app/components/05.forms/input-select/InputSelect.vue +198 -0
  43. package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
  44. package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
  45. package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
  46. package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
  47. package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
  48. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
  49. package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
  50. package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
  51. package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
  52. package/app/components/05.forms/input-text/InputTextField.vue +168 -0
  53. package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
  54. package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
  55. package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
  56. package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
  57. package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
  58. package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
  59. package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
  60. package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
  61. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
  62. package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
  63. package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
  64. package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
  65. package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
  66. package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
  67. package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
  68. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
  69. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
  70. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +30 -32
  71. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
  72. package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
  73. package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
  74. package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
  75. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
  76. package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
  77. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
  78. package/package.json +1 -1
  79. package/.claude/skills/components/input-select-core.md +0 -151
  80. package/.claude/skills/components/input-text-core.md +0 -204
  81. package/.claude/skills/components/input-textarea-core.md +0 -154
  82. package/.claude/skills/components/toggle-switch-core.md +0 -169
  83. package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
  84. package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
  85. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
  86. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
  87. package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
  88. package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
  89. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
  90. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
  91. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
  92. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
  93. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
  94. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
  95. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
  96. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
  97. package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
  98. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
  99. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
  100. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
  101. package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
  102. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
  103. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
  104. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
  105. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
  106. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
  107. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
  108. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
@@ -0,0 +1,187 @@
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputSelectField from "../InputSelectField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { FormUiTheme, InputUiVariant, IFormMultipleOptions, IOptionsConfig } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+
8
+ interface StoryArgs {
9
+ modelValue: string | number;
10
+ name: string;
11
+ label: string;
12
+ placeholder: string;
13
+ title: string;
14
+ errorMessage: string;
15
+ fieldHasError: boolean;
16
+ required: boolean;
17
+ theme: FormUiTheme;
18
+ inputVariant: InputUiVariant;
19
+ styleClassPassthrough: string[];
20
+ descriptionText: string;
21
+ options: IOptionsConfig[];
22
+ }
23
+
24
+ const colourOptions: IOptionsConfig[] = [
25
+ { id: "1", name: "red", value: "red", label: "Red" },
26
+ { id: "2", name: "blue", value: "blue", label: "Blue" },
27
+ { id: "3", name: "green", value: "green", label: "Green" },
28
+ ];
29
+
30
+ const meta: Meta<StoryArgs> = {
31
+ title: "Components/Forms/Input Select/InputSelectField",
32
+ component: InputSelectField,
33
+ argTypes: {
34
+ modelValue: { control: "text", table: { category: "Model" } },
35
+ name: { control: "text", table: { category: "Basic" } },
36
+ label: { control: "text", table: { category: "Basic" } },
37
+ placeholder: { control: "text", table: { category: "Basic" } },
38
+ title: { control: "text", description: "Native tooltip; pass translated copy", table: { category: "Accessibility" } },
39
+ errorMessage: { control: "text", table: { category: "Validation" } },
40
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
41
+ required: { control: "boolean", table: { category: "Validation" } },
42
+ theme: {
43
+ control: { type: "select" },
44
+ options: ["default", "success", "error", "warning"],
45
+ table: { category: "Styling" },
46
+ },
47
+ inputVariant: {
48
+ control: { type: "select" },
49
+ options: ["normal", "outlined", "underlined"],
50
+ table: { category: "Styling" },
51
+ },
52
+ styleClassPassthrough: { control: "object", table: { category: "Styling" } },
53
+ descriptionText: {
54
+ control: "text",
55
+ description: "Story-only: fills the descriptionText slot",
56
+ table: { category: "Slots" },
57
+ },
58
+ options: {
59
+ control: "object",
60
+ description: "Story-only: the `data` array of `v-model:field-data`",
61
+ table: { category: "Model" },
62
+ },
63
+ },
64
+ args: {
65
+ modelValue: "",
66
+ name: "colour",
67
+ label: "Favourite colour",
68
+ placeholder: "Choose a colour",
69
+ title: "Please select an option",
70
+ errorMessage: "",
71
+ fieldHasError: false,
72
+ required: false,
73
+ theme: "default",
74
+ inputVariant: "normal",
75
+ styleClassPassthrough: [],
76
+ descriptionText: "",
77
+ options: colourOptions,
78
+ },
79
+ decorators: [
80
+ (story, context) => ({
81
+ components: { story, CanvasSwitcher },
82
+ setup() {
83
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
84
+ return { canvasName };
85
+ },
86
+ template: `
87
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
88
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
89
+ </div>
90
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
91
+ <story />
92
+ </div>
93
+ `,
94
+ }),
95
+ ],
96
+ parameters: {
97
+ layout: "fullscreen",
98
+ docs: {
99
+ description: {
100
+ component:
101
+ "Labelled select field: `InputLabel`, an optional description (`descriptionText`/`descriptionHtml` slots, moved after the control for the `outlined` variant), `InputSelect` and an error strip linked through `aria-describedby`. `field-has-error` switches the control to the `error` theme. `style-class-passthrough` lands on the `.input-select-field` root only.",
102
+ },
103
+ },
104
+ },
105
+ };
106
+
107
+ export default meta;
108
+ type Story = StoryObj<StoryArgs>;
109
+
110
+ const render = (args: StoryArgs) => ({
111
+ components: { InputSelectField },
112
+ setup() {
113
+ const fieldData = computed<IFormMultipleOptions>(() => ({
114
+ data: args.options,
115
+ total: args.options.length,
116
+ skip: 0,
117
+ limit: args.options.length,
118
+ }));
119
+ const componentArgs = computed(() => {
120
+ const { modelValue: _modelValue, descriptionText: _descriptionText, options: _options, ...rest } = args;
121
+ return rest;
122
+ });
123
+ return { args, componentArgs, fieldData };
124
+ },
125
+ template: `
126
+ <InputSelectField v-model="args.modelValue" :field-data="fieldData" v-bind="componentArgs">
127
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
128
+ </InputSelectField>
129
+ <p style="margin-block-start: 1.6rem; font-family: monospace;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
130
+ `,
131
+ });
132
+
133
+ export const Default: Story = { render };
134
+
135
+ export const WithError: Story = {
136
+ args: { fieldHasError: true, errorMessage: "Please choose a colour" },
137
+ render,
138
+ };
139
+
140
+ export const Required: Story = { args: { required: true }, render };
141
+
142
+ export const WithDescription: Story = { args: { descriptionText: "Pick the colour you like best" }, render };
143
+
144
+ export const Underlined: Story = { args: { inputVariant: "underlined" }, render };
145
+
146
+ export const Outlined: Story = {
147
+ args: { inputVariant: "outlined", descriptionText: "Pick the colour you like best" },
148
+ render,
149
+ parameters: { docs: { description: { story: "The `outlined` variant renders the description after the control and error." } } },
150
+ };
151
+
152
+ export const StressTest: Story = {
153
+ name: "Stress Test (Worst-Case Data)",
154
+ args: {
155
+ modelValue: "long",
156
+ label:
157
+ "🎨 Bevorzugtefarbefürdiegesamtegestaltungderbenutzeroberfläche https://example.com/a/very/long/unbroken/url <b>not bold</b> اللون",
158
+ descriptionText: "يرجى اختيار لون Wählen Sie bitte die Farbe, die Ihnen für die Oberfläche am besten gefällt.",
159
+ placeholder: "Bitte wählen Sie eine Farbe aus der folgenden sehr langen Liste aus",
160
+ errorMessage: "DieausgewähltefarbeistleidernichtverfügbarbitteversuchenSieeserneut",
161
+ fieldHasError: true,
162
+ required: true,
163
+ options: [
164
+ { id: "0", name: "zero", value: "0", label: "0" },
165
+ { id: "1", name: "long", value: "long", label: "Dunkelblaugrüngrauschimmerndemitternachtsfarbemitmetallischemglanz" },
166
+ { id: "2", name: "html", value: "html", label: "<i>italic?</i>" },
167
+ { id: "3", name: "rtl", value: "rtl", label: "أزرق" },
168
+ ...Array.from({ length: 20 }, (_, index) => ({
169
+ id: `n${index}`,
170
+ name: `n${index}`,
171
+ value: `n${index}`,
172
+ label: `Colour ${index + 1}`,
173
+ })),
174
+ ],
175
+ },
176
+ render,
177
+ parameters: {
178
+ initialCanvas: "mobileCanvas",
179
+ docs: {
180
+ story: { inline: false, height: "520px" },
181
+ description: {
182
+ story:
183
+ "Long unbroken German, URL, emoji, HTML-like and right-to-left copy in the label, description, placeholder, error and options, with a long unbroken label selected. Check at every canvas width: the label, description and error wrap inside the dashed outline, the select never pushes past it, and markup renders as text.",
184
+ },
185
+ },
186
+ },
187
+ };
@@ -1,7 +1,8 @@
1
1
  // https://nuxt.com/docs/getting-started/testing#unit-testing
2
+ import { nextTick } from "vue";
2
3
  import type { VueWrapper } from "@vue/test-utils";
3
4
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
- import ComponentUnderTest from "../InputSelectCore.vue";
5
+ import ComponentUnderTest from "../InputSelect.vue";
5
6
 
6
7
  const initialFieldData = {
7
8
  data: [
@@ -30,35 +31,63 @@ const wrapperFactory = (propsData = {}) => {
30
31
  });
31
32
  };
32
33
 
33
- describe("InputSelectCore", () => {
34
+ afterEach(() => {
35
+ wrapper?.unmount();
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ describe("InputSelect", () => {
34
40
  it("mounts without error", async () => {
35
41
  wrapper = await wrapperFactory();
36
42
  expect(wrapper.vm).toBeTruthy();
37
43
  });
38
44
 
45
+ it("renders the native select with the .input-select class", async () => {
46
+ wrapper = await wrapperFactory();
47
+ expect(wrapper.find("select.input-select").exists()).toBe(true);
48
+ });
49
+
39
50
  it("renders an option per fieldData item", async () => {
40
51
  wrapper = await wrapperFactory();
41
- const options = wrapper.findAll("option");
52
+ const options = wrapper.findAll("option.input-select-option");
42
53
  expect(options.map((o) => o.text())).toEqual(["Red", "Blue"]);
43
54
  });
44
55
 
45
56
  it("binds v-model to the select value", async () => {
46
57
  wrapper = await wrapperFactory({ modelValue: "blue" });
47
- expect(wrapper.find("select").element.value).toBe("blue");
58
+ expect((wrapper.find("select").element as HTMLSelectElement).value).toBe("blue");
59
+ });
60
+
61
+ it("selects an option whose value is 0", async () => {
62
+ wrapper = await wrapperFactory({
63
+ placeholder: "Pick a number",
64
+ modelValue: 0,
65
+ fieldData: { ...initialFieldData, data: [{ id: "0", name: "zero", value: 0, label: "Zero" }] },
66
+ });
67
+ expect((wrapper.find("select").element as HTMLSelectElement).selectedOptions[0]?.textContent?.trim()).toBe("Zero");
48
68
  });
49
69
 
50
70
  it("renders a disabled placeholder option when placeholder is set", async () => {
51
71
  wrapper = await wrapperFactory({ placeholder: "Choose a colour", modelValue: "" });
52
- const placeholderOption = wrapper.find("option.placeholder");
72
+ const placeholderOption = wrapper.find("option.input-select-option-placeholder");
53
73
 
54
74
  expect(placeholderOption.exists()).toBe(true);
55
75
  expect(placeholderOption.text()).toBe("Choose a colour");
56
76
  expect(placeholderOption.attributes("disabled")).toBeDefined();
77
+ expect((placeholderOption.element as HTMLOptionElement).selected).toBe(true);
57
78
  });
58
79
 
59
80
  it("does not render a placeholder option when placeholder is unset", async () => {
60
81
  wrapper = await wrapperFactory();
61
- expect(wrapper.find("option.placeholder").exists()).toBe(false);
82
+ expect(wrapper.find("option.input-select-option-placeholder").exists()).toBe(false);
83
+ });
84
+
85
+ it("renders option labels as text, not markup", async () => {
86
+ wrapper = await wrapperFactory({
87
+ fieldData: { ...initialFieldData, data: [{ id: "1", name: "x", value: "x", label: "<b>bold</b>" }] },
88
+ });
89
+ expect(wrapper.find("option b").exists()).toBe(false);
90
+ expect(wrapper.find("option").text()).toBe("<b>bold</b>");
62
91
  });
63
92
 
64
93
  it("sets required attribute when required is true", async () => {
@@ -71,6 +100,22 @@ describe("InputSelectCore", () => {
71
100
  expect(wrapper.find("select").attributes("required")).toBeUndefined();
72
101
  });
73
102
 
103
+ it("forwards title, defaulting to the English copy", async () => {
104
+ wrapper = await wrapperFactory();
105
+ expect(wrapper.find("select").attributes("title")).toBe("Please select an option");
106
+
107
+ await wrapper.setProps({ title: "Veuillez choisir" });
108
+ expect(wrapper.find("select").attributes("title")).toBe("Veuillez choisir");
109
+ });
110
+
111
+ it("omits aria-describedby when empty and forwards it when set", async () => {
112
+ wrapper = await wrapperFactory();
113
+ expect(wrapper.find("select").attributes("aria-describedby")).toBeUndefined();
114
+
115
+ await wrapper.setProps({ ariaDescribedby: "hint-id" });
116
+ expect(wrapper.find("select").attributes("aria-describedby")).toBe("hint-id");
117
+ });
118
+
74
119
  it("sets data-theme from the theme prop", async () => {
75
120
  wrapper = await wrapperFactory({ theme: "error" });
76
121
  expect(wrapper.find(".input-select-wrapper").attributes("data-theme")).toBe("error");
@@ -82,19 +127,30 @@ describe("InputSelectCore", () => {
82
127
  expect(wrapper.find("select").attributes("aria-invalid")).toBe("true");
83
128
  });
84
129
 
85
- it("defaults inputVariant to normal", async () => {
130
+ it("omits data-invalid and aria-invalid by default", async () => {
86
131
  wrapper = await wrapperFactory();
87
- expect(wrapper.find(".input-select-wrapper").classes()).toContain("normal");
132
+ expect(wrapper.find(".input-select-wrapper").attributes("data-invalid")).toBeUndefined();
133
+ expect(wrapper.find("select").attributes("aria-invalid")).toBeUndefined();
88
134
  });
89
135
 
90
- it("applies inputVariant as a class on the wrapper", async () => {
136
+ it("defaults data-input-variant to normal", async () => {
137
+ wrapper = await wrapperFactory();
138
+ expect(wrapper.find(".input-select-wrapper").attributes("data-input-variant")).toBe("normal");
139
+ });
140
+
141
+ it("sets data-input-variant from inputVariant", async () => {
91
142
  wrapper = await wrapperFactory({ inputVariant: "underlined" });
92
- expect(wrapper.find(".input-select-wrapper").classes()).toContain("underlined");
143
+ expect(wrapper.find(".input-select-wrapper").attributes("data-input-variant")).toBe("underlined");
93
144
  });
94
145
 
95
- it("applies styleClassPassthrough to the select", async () => {
146
+ it("applies styleClassPassthrough to the select and reacts to changes", async () => {
96
147
  wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-select-class"] });
97
148
  expect(wrapper.find("select").classes()).toContain("custom-select-class");
149
+
150
+ await wrapper.setProps({ styleClassPassthrough: ["other-class"] });
151
+ await nextTick();
152
+ expect(wrapper.find("select").classes()).toContain("other-class");
153
+ expect(wrapper.find("select").classes()).not.toContain("custom-select-class");
98
154
  });
99
155
 
100
156
  it("sets isActive true on focus and false on blur", async () => {
@@ -114,6 +170,13 @@ describe("InputSelectCore", () => {
114
170
  expect(wrapper.emitted("update:isDirty")?.at(-1)).toEqual([true]);
115
171
  });
116
172
 
173
+ it("reflects isDirty and isActive as data attributes", async () => {
174
+ wrapper = await wrapperFactory({ isDirty: true, isActive: true });
175
+ const root = wrapper.find(".input-select-wrapper");
176
+ expect(root.attributes("data-dirty")).toBe("");
177
+ expect(root.attributes("data-active")).toBe("");
178
+ });
179
+
117
180
  it("renders an icon for an option that has one, hidden from assistive tech", async () => {
118
181
  wrapper = await wrapperFactory({
119
182
  fieldData: {
@@ -122,7 +185,7 @@ describe("InputSelectCore", () => {
122
185
  },
123
186
  });
124
187
 
125
- const icon = wrapper.find(".input-select-core-option-decorator-icon");
188
+ const icon = wrapper.find(".input-select-option-icon");
126
189
  expect(icon.exists()).toBe(true);
127
190
  expect(icon.attributes("aria-hidden")).toBe("true");
128
191
  });
@@ -0,0 +1,121 @@
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 "../InputSelectField.vue";
6
+
7
+ const initialFieldData = {
8
+ data: [
9
+ { id: "1", name: "red", value: "red", label: "Red" },
10
+ { id: "2", name: "blue", value: "blue", label: "Blue" },
11
+ ],
12
+ total: 2,
13
+ skip: 0,
14
+ limit: 10,
15
+ };
16
+
17
+ const initialPropsData = {
18
+ name: "colour",
19
+ label: "Colour",
20
+ errorMessage: "",
21
+ modelValue: "red",
22
+ fieldData: initialFieldData,
23
+ };
24
+
25
+ let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
26
+ const wrapperFactory = (propsData = {}, slots = {}) => {
27
+ const mockPropsData = { ...initialPropsData, ...propsData };
28
+
29
+ return mountSuspended(ComponentUnderTest, {
30
+ attachTo: document.body,
31
+ props: mockPropsData,
32
+ slots,
33
+ });
34
+ };
35
+
36
+ afterEach(() => {
37
+ wrapper?.unmount();
38
+ vi.restoreAllMocks();
39
+ });
40
+
41
+ describe("InputSelectField", () => {
42
+ it("mounts without error", async () => {
43
+ wrapper = await wrapperFactory();
44
+ expect(wrapper.vm).toBeTruthy();
45
+ });
46
+
47
+ it("renders .input-select-field as the root", async () => {
48
+ wrapper = await wrapperFactory();
49
+ expect(wrapper.classes()).toContain("input-select-field");
50
+ });
51
+
52
+ it("renders the label text, linked to the select", async () => {
53
+ wrapper = await wrapperFactory({ label: "Favourite colour" });
54
+ const label = wrapper.find("label");
55
+ expect(label.text()).toContain("Favourite colour");
56
+ expect(label.attributes("for")).toBe(wrapper.find("select").attributes("id"));
57
+ });
58
+
59
+ it("renders a native select with an option per fieldData item", async () => {
60
+ wrapper = await wrapperFactory();
61
+ expect(wrapper.find("select").exists()).toBe(true);
62
+ expect(wrapper.findAll("option").map((o) => o.text())).toEqual(["Red", "Blue"]);
63
+ });
64
+
65
+ it("sets data-invalid on the root when fieldHasError is true", async () => {
66
+ wrapper = await wrapperFactory({ fieldHasError: true });
67
+ expect(wrapper.attributes("data-invalid")).toBe("");
68
+ });
69
+
70
+ it("renders the error message and links it via aria-describedby", async () => {
71
+ wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "Please choose a colour" });
72
+ expect(wrapper.text()).toContain("Please choose a colour");
73
+ const describedBy = wrapper.find("select").attributes("aria-describedby") ?? "";
74
+ expect(describedBy.length).toBeGreaterThan(0);
75
+ });
76
+
77
+ it("renders the descriptionText slot before the select by default", async () => {
78
+ wrapper = await wrapperFactory({}, { descriptionText: () => "Pick your favourite" });
79
+ const html = wrapper.html();
80
+ expect(wrapper.text()).toContain("Pick your favourite");
81
+ expect(html.indexOf("Pick your favourite")).toBeLessThan(html.indexOf("<select"));
82
+ });
83
+
84
+ it("renders the description after the select for the outlined variant", async () => {
85
+ wrapper = await wrapperFactory({ inputVariant: "outlined" }, { descriptionText: () => "Pick your favourite" });
86
+ const html = wrapper.html();
87
+ expect(html.indexOf("Pick your favourite")).toBeGreaterThan(html.indexOf("<select"));
88
+ });
89
+
90
+ it("sets data-input-variant on the root", async () => {
91
+ wrapper = await wrapperFactory({ inputVariant: "underlined" });
92
+ expect(wrapper.attributes("data-input-variant")).toBe("underlined");
93
+ });
94
+
95
+ it("applies styleClassPassthrough to its root only and reacts to changes", async () => {
96
+ wrapper = await wrapperFactory({ styleClassPassthrough: "custom-select" });
97
+ expect(wrapper.classes()).toContain("custom-select");
98
+ expect(wrapper.find("select").classes()).not.toContain("custom-select");
99
+
100
+ await wrapper.setProps({ styleClassPassthrough: "other-select" });
101
+ await nextTick();
102
+ expect(wrapper.classes()).toContain("other-select");
103
+ expect(wrapper.classes()).not.toContain("custom-select");
104
+ });
105
+
106
+ it("forwards required and title to the native select", async () => {
107
+ wrapper = await wrapperFactory({ required: true, title: "Choisissez" });
108
+ expect(wrapper.find("select").attributes("required")).toBeDefined();
109
+ expect(wrapper.find("select").attributes("title")).toBe("Choisissez");
110
+ });
111
+
112
+ it("applies the error theme to InputSelect when fieldHasError is true", async () => {
113
+ wrapper = await wrapperFactory({ fieldHasError: true, theme: "default" });
114
+ expect(wrapper.find(".input-select-wrapper").attributes("data-theme")).toBe("error");
115
+ });
116
+
117
+ it("defaults data-testid to input-select-field", async () => {
118
+ wrapper = await wrapperFactory();
119
+ expect(wrapper.attributes("data-testid")).toBe("input-select-field");
120
+ });
121
+ });
@@ -1,4 +1,9 @@
1
- # InputTextCore — Consumer Styling Guide
1
+ # InputText — Consumer Styling Guide
2
+
3
+ > **Renamed 2026-10-16**: `InputTextCore` → `InputText`, `InputTextWithLabel` →
4
+ > `InputTextField`, `InputPasswordWithLabel` → `InputPasswordField`,
5
+ > `InputTextAsNumberWithLabel` → `InputTextAsNumberField`. Class changes are listed under "State
6
+ > hooks" below.
2
7
 
3
8
  ## Public token API
4
9
 
@@ -7,25 +12,69 @@ override just this component; leave it unset and it inherits the shared `--theme
7
12
  `--theme-*` token every other themed component also falls back to. See
8
13
  `theming-component-token-pattern.md` for why this two-tier shape exists.
9
14
 
10
- (Before 2026-08-25 this component used a private `--_input-text-*` naming scheme presented as a
11
- "local override path" — that's gone. A private, underscore-prefixed custom property was never a
12
- real override point; renaming it public with a real fallback is the actual fix, not a second
13
- documented path to reach for.)
14
-
15
- | Token | Falls back to | Controls |
15
+ | Token | Default | Controls |
16
16
  |---|---|---|
17
- | `--input-text-surface` | `var(--theme-input-surface)` | Wrapper background |
18
- | `--input-text-surface-hover` | `var(--theme-input-surface-hover)` | Background of an embedded `InputButton` slot on hover |
19
- | `--input-text-border` | `var(--theme-border)` | Border colour (`.normal`/`.underlined`, resting) |
20
- | `--input-text-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (mouse) |
21
- | `--input-text-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive) |
22
- | `--input-text-color` | `var(--theme-input-text-color-normal)` | Input text colour |
17
+ | `--input-text-surface` | `var(--theme-input-surface)` | Wrapper background, and slot `InputButton` background |
18
+ | `--input-text-surface-hover` | `var(--theme-input-surface-hover)` | Slot `InputButton` background on hover |
19
+ | `--input-text-border` | `var(--theme-border)` | Border colour (`normal`/`underlined`, resting), and the stepper's button dividers |
20
+ | `--input-text-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (`normal` only) |
21
+ | `--input-text-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (`normal` only), and a slot button's own focus outline |
22
+ | `--input-text-color` | `var(--theme-input-text-color-normal)` | Input text and slot button colour, and autofilled text (global rule) |
23
23
  | `--input-text-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
24
24
 
25
- Geometry (`--input-padding-*`, `--input-min-height`, `--input-font-size`,
25
+ Geometry (`--input-padding-*`, `--input-min-height`, `--input-font-size`, `--input-icon-slot-gap`,
26
26
  `--form-element-*`, etc.) is shared across every `05.forms` component and untouched by this —
27
27
  see `theming-form-geometry-tokens.md`.
28
28
 
29
+ Private (not public API): `--_surface`, `--_border` and `--_border-focus`, each resolved once and
30
+ read in several places.
31
+
32
+ ---
33
+
34
+ ## State hooks
35
+
36
+ | Attribute | On | When |
37
+ |---|---|---|
38
+ | `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-text-wrapper`, each Field root | Always, mirrors `input-variant` |
39
+ | `data-theme` | `.input-text-wrapper`, each Field root | Always, mirrors `theme` |
40
+ | `data-invalid` | `.input-text-wrapper`, each Field root | `field-has-error` |
41
+ | `data-dirty` | `.input-text-wrapper`, each Field root | The Field has seen a non-empty value |
42
+ | `data-active` | `.input-text-wrapper`, each Field root | The input has focus |
43
+ | `data-readonly` | `.input-text-wrapper` | `readonly` |
44
+ | `data-inputmode` | `.input-text-wrapper` | Always, mirrors `inputmode` |
45
+ | `data-has-left-slot` / `data-has-right-slot` | `.input-text-wrapper` | The `left` / `right` slot is filled |
46
+
47
+ Field roots: `.input-text-field` (`InputTextField`, and `InputPasswordField`, which adds
48
+ `.input-password-field`), `.input-text-as-number-field` (`InputTextAsNumberField`).
49
+
50
+ Inner elements: `.input-text` (the native input), `.input-text-slot` (both slot holders),
51
+ `.input-text-slot-left`, `.input-text-slot-right`, `.input-text-label` (the Fields' `InputLabel`),
52
+ `.input-text-description` (the Fields' `InputDescription`), `.input-password-field-icon` (the
53
+ password toggle's icon).
54
+
55
+ `outlined` has no CSS of its own: the control renders with no border or outline. In
56
+ `InputTextField` it moves the description after the input and error.
57
+
58
+ > **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
59
+ > (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
60
+ > above instead:
61
+ >
62
+ > - `.input-text-wrapper.normal` / `.underlined` / `.outlined` → `[data-input-variant="…"]`
63
+ > - `.input-text-wrapper.dirty` / `.active` / `.error` → `[data-dirty]` / `[data-active]` /
64
+ > `[data-invalid]`; `.has-left-slot` / `.has-right-slot` → `[data-has-left-slot]` /
65
+ > `[data-has-right-slot]`
66
+ > - `.slot.left-slot` / `.slot.right-slot` → `.input-text-slot-left` / `.input-text-slot-right`
67
+ > - `.input-text-core` → `.input-text` (it also no longer carries `dirty`/`active` classes)
68
+ > - `.input-text-with-label` → `.input-text-field`, now the Field's root (it used to sit inside
69
+ > an unclassed `<div>`); its variant/`dirty`/`active` classes → the attributes above
70
+ > - `InputTextAsNumberField`'s root was `.input-text-with-label.input-text-as-number`; it's now
71
+ > `.input-text-as-number-field`, and the input no longer gets `.input-text-as-number`
72
+ > - the password toggle icon's `.icon` → `.input-password-field-icon`
73
+ >
74
+ > Also: label, description and error copy wraps (`overflow-wrap: anywhere` on each Field root),
75
+ > and the input can shrink below its intrinsic width (`min-inline-size: 0`), so a field no longer
76
+ > overflows a narrow column.
77
+
29
78
  ---
30
79
 
31
80
  ## Global theming
@@ -51,9 +100,30 @@ Set the tokens above on an element you own (a page or section class, or a class
51
100
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
52
101
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
53
102
 
103
+ **Caveat**: `InputText`'s own passthrough lands on the `<input>`, which has no descendants, so
104
+ tokens set through it only reach the input itself (text and placeholder colour). Set the others on
105
+ a Field root or an ancestor.
106
+
54
107
  ### One instance
55
108
 
56
109
  ```vue
57
- <InputTextCore id="email" name="email" style="--input-text-border-focus: var(--gold-04);" />
110
+ <InputTextField
111
+ v-model="email"
112
+ name="email"
113
+ type="email"
114
+ label="Email"
115
+ error-message=""
116
+ style="--input-text-border-focus: var(--gold-04);"
117
+ />
58
118
  ```
59
119
 
120
+ ---
121
+
122
+ ## Class passthrough
123
+
124
+ `InputText`'s `:style-class-passthrough` lands on the native `<input class="input-text">`. Each
125
+ Field's lands on its own root only.
126
+
127
+ > **Changed 2026-10-16**: `InputTextField` and `InputTextAsNumberField` applied their passthrough
128
+ > classes to both their root and the inner input. If you styled the input that way, select
129
+ > `.your-class .input-text` instead.