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
@@ -1,7 +1,7 @@
1
1
  // https://nuxt.com/docs/getting-started/testing#unit-testing
2
2
  import type { VueWrapper } from "@vue/test-utils";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
- import ComponentUnderTest from "../InputRangeDefault.vue";
4
+ import ComponentUnderTest from "../InputRangeField.vue";
5
5
 
6
6
  const initialPropsData = {
7
7
  name: "testName",
@@ -13,6 +13,11 @@ const initialPropsData = {
13
13
  };
14
14
 
15
15
  let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
16
+
17
+ afterEach(() => {
18
+ wrapper?.unmount();
19
+ vi.restoreAllMocks();
20
+ });
16
21
  const wrapperFactory = (propsData = {}, slots = {}) => {
17
22
  const mockPropsData = { ...initialPropsData, ...propsData };
18
23
 
@@ -23,7 +28,7 @@ const wrapperFactory = (propsData = {}, slots = {}) => {
23
28
  });
24
29
  };
25
30
 
26
- describe("InputRangeDefault", () => {
31
+ describe("InputRangeField", () => {
27
32
  it("mounts without error", async () => {
28
33
  wrapper = await wrapperFactory();
29
34
  expect(wrapper.vm).toBeTruthy();
@@ -39,10 +44,10 @@ describe("InputRangeDefault", () => {
39
44
  expect(wrapper.find('input[type="range"]').exists()).toBe(true);
40
45
  });
41
46
 
42
- it("sets data-invalid and the error class when fieldHasError is true", async () => {
47
+ it("sets data-invalid, not a bare error class, when fieldHasError is true", async () => {
43
48
  wrapper = await wrapperFactory({ fieldHasError: true });
44
- expect(wrapper.find(".input-range-with-label").attributes("data-invalid")).toBe("");
45
- expect(wrapper.find(".input-range-with-label").classes()).toContain("error");
49
+ expect(wrapper.find(".input-range-field").attributes("data-invalid")).toBe("");
50
+ expect(wrapper.find(".input-range-field").classes()).not.toContain("error");
46
51
  });
47
52
 
48
53
  it("does not render step buttons when the left/right slots are not used", async () => {
@@ -133,8 +138,67 @@ describe("InputRangeDefault", () => {
133
138
  expect(wrapper.text()).toContain("This field is required");
134
139
  });
135
140
 
136
- it("applies styleClassPassthrough", async () => {
141
+ it("applies styleClassPassthrough to the root only, not the inner input", async () => {
137
142
  wrapper = await wrapperFactory({ styleClassPassthrough: "custom-range" });
138
- expect(wrapper.find(".input-range-with-label").classes()).toContain("custom-range");
143
+ expect(wrapper.find(".input-range-field").classes()).toContain("custom-range");
144
+ expect(wrapper.find("input").classes()).not.toContain("custom-range");
145
+ });
146
+
147
+ it("uses its own label class, not the number field's", async () => {
148
+ wrapper = await wrapperFactory();
149
+ expect(wrapper.find(".input-range-label").exists()).toBe(true);
150
+ expect(wrapper.find(".input-number-label").exists()).toBe(false);
151
+ });
152
+
153
+ it("forwards the markers slot", async () => {
154
+ wrapper = await wrapperFactory({}, { markers: '<div class="input-range-markers" data-testid="markers"></div>' });
155
+ expect(wrapper.find('[data-testid="markers"]').exists()).toBe(true);
156
+ expect(wrapper.find(".input-range-wrapper").attributes("data-has-markers")).toBe("");
157
+ });
158
+
159
+ it("forwards the datalist slot with its generated id", async () => {
160
+ wrapper = await wrapperFactory(
161
+ {},
162
+ { datalist: `<template #datalist="{ datalistId }"><datalist :id="datalistId"></datalist></template>` }
163
+ );
164
+ const list = wrapper.find("input").attributes("list");
165
+
166
+ expect(list).toBeTruthy();
167
+ expect(wrapper.find("datalist").attributes("id")).toBe(list);
168
+ });
169
+
170
+ describe("step buttons", () => {
171
+ const stepSlots = { left: "<span>-</span>", right: "<span>+</span>" };
172
+ const stepDown = () => wrapper.findAll("button").find((button) => button.text().includes("-"))!;
173
+ const stepUp = () => wrapper.findAll("button").find((button) => button.text().includes("+"))!;
174
+
175
+ it("rounds away floating-point drift on fractional steps", async () => {
176
+ wrapper = await wrapperFactory({ modelValue: 0.2, min: 0, max: 1, step: 0.1 }, stepSlots);
177
+ await stepUp().trigger("click");
178
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([0.3]);
179
+ });
180
+
181
+ it("clamps to max when a step would overshoot it", async () => {
182
+ wrapper = await wrapperFactory({ modelValue: 98, min: 0, max: 100, step: 5 }, stepSlots);
183
+ await stepUp().trigger("click");
184
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([100]);
185
+ });
186
+
187
+ it("clamps to min when a step would undershoot it", async () => {
188
+ wrapper = await wrapperFactory({ modelValue: 2, min: 0, max: 100, step: 5 }, stepSlots);
189
+ await stepDown().trigger("click");
190
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([0]);
191
+ });
192
+
193
+ it("treats a negative or zero step as its size, or 1", async () => {
194
+ wrapper = await wrapperFactory({ modelValue: 50, step: -5 }, stepSlots);
195
+ await stepUp().trigger("click");
196
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([55]);
197
+
198
+ wrapper.unmount();
199
+ wrapper = await wrapperFactory({ modelValue: 50, step: 0 }, stepSlots);
200
+ await stepUp().trigger("click");
201
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([51]);
202
+ });
139
203
  });
140
204
  });
@@ -1,4 +1,7 @@
1
- # InputSelectCore — Consumer Styling Guide
1
+ # InputSelect — Consumer Styling Guide
2
+
3
+ > **Renamed 2026-10-16**: `InputSelectCore` → `InputSelect`, `InputSelectWithLabel` →
4
+ > `InputSelectField`. Class changes are listed under "State hooks" below.
2
5
 
3
6
  ## Public token API
4
7
 
@@ -7,23 +10,73 @@ to override just this component; leave it unset and it inherits the shared `--th
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
- (Before 2026-08-25 this component used a private `--_input-select-*` 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.)
13
+ ### Control
14
14
 
15
- | Token | Falls back to | Controls |
15
+ | Token | Default | Controls |
16
16
  |---|---|---|
17
17
  | `--input-select-surface` | `var(--theme-input-surface)` | Wrapper/select background |
18
- | `--input-select-surface-hover` | `var(--theme-input-surface-hover)` | Option row background on hover |
19
- | `--input-select-border` | `var(--theme-border)` | Border colour (`.normal`/`.underlined`/open picker, resting) |
18
+ | `--input-select-text-color` | `var(--theme-input-text-color-normal)` | Select text colour (added 2026-10-16; it inherited the page colour before) |
19
+ | `--input-select-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variants and the open picker, resting) |
20
20
  | `--input-select-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (mouse) |
21
- | `--input-select-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive) |
21
+ | `--input-select-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive), and the open picker's outline |
22
+
23
+ ### Options
24
+
25
+ | Token | Default | Controls |
26
+ |---|---|---|
27
+ | `--input-select-surface-hover` | `var(--theme-input-surface-hover)` | Option row background on hover |
28
+ | `--input-select-option-gap` | `1.5rem` | Gap between an option's icon and its label |
29
+ | `--input-select-option-icon-size` | `1.5rem` | Option icon width and height |
22
30
 
23
31
  Geometry (`--input-padding-*`, `--input-min-height`, `--input-select-option-min-height`,
24
32
  `--form-element-*`, etc.) is shared across every `05.forms` component and untouched by this — see
25
33
  `theming-form-geometry-tokens.md`.
26
34
 
35
+ Private (not public API): `--_border` and `--_border-focus`, each resolved once and read in several
36
+ places.
37
+
38
+ Option rows (`--input-select-surface-hover`, the gap and the icon) and the open picker's border are
39
+ only styled where the browser supports `appearance: base-select`. Elsewhere the native dropdown
40
+ shows and ignores them.
41
+
42
+ ---
43
+
44
+ ## State hooks
45
+
46
+ | Attribute | On | When |
47
+ |---|---|---|
48
+ | `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-select-wrapper`, `.input-select-field` | Always, mirrors `input-variant` |
49
+ | `data-theme` | `.input-select-wrapper`, `.input-select-field` | Always. The Field's own is the `theme` prop; the inner wrapper's switches to `error` while `field-has-error` is set |
50
+ | `data-invalid` | `.input-select-wrapper`, `.input-select-field` | `field-has-error` |
51
+ | `data-dirty` | `.input-select-wrapper` | The value has changed (the `is-dirty` model) |
52
+ | `data-active` | `.input-select-wrapper` | The select has focus (the `is-active` model) |
53
+
54
+ Inner elements: `.input-select` (the native select), `.input-select-option` (each option),
55
+ `.input-select-option-placeholder` (the placeholder option), `.input-select-option-icon`,
56
+ `.input-select-label` (the Field's `InputLabel`), `.input-text-description` (the Field's
57
+ `InputDescription`, shared with the other Field components).
58
+
59
+ `outlined` has no CSS of its own: the control renders with no border or outline. In the Field it
60
+ moves the description after the control and error.
61
+
62
+ > **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
63
+ > (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
64
+ > above instead:
65
+ >
66
+ > - `.input-select-wrapper.normal` / `.underlined` / `.outlined` → `[data-input-variant="…"]`
67
+ > - `.input-select-wrapper.dirty` / `.active` / `.error` → `[data-dirty]` / `[data-active]` /
68
+ > `[data-invalid]`
69
+ > - `.input-select-core` → `.input-select`; `.input-select-core-option` → `.input-select-option`;
70
+ > `option.placeholder` → `.input-select-option-placeholder`;
71
+ > `.input-select-core-option-decorator-icon` → `.input-select-option-icon`
72
+ > - `.input-select-with-label` → `.input-select-field`, which is now the root (it used to sit
73
+ > inside an unclassed `<div>`); its `.error`, variant, `dirty` and `active` classes are gone, use
74
+ > `[data-invalid]` / `[data-input-variant]`, or the inner wrapper's attributes
75
+ > - the Field's default `data-testid` is `input-select-field` (was `input-select-with-label`)
76
+ >
77
+ > Also: long label, description and error copy now wraps (`overflow-wrap: anywhere` on
78
+ > `.input-select-field`), and long option labels wrap in the open picker.
79
+
27
80
  ---
28
81
 
29
82
  ## Global theming
@@ -52,6 +105,23 @@ Set the tokens above on an element you own (a page or section class, or a class
52
105
  ### One instance
53
106
 
54
107
  ```vue
55
- <InputSelectCore id="country" name="country" style="--input-select-border-focus: var(--gold-04);" />
108
+ <InputSelectField
109
+ v-model="country"
110
+ v-model:field-data="countries"
111
+ name="country"
112
+ label="Country"
113
+ :error-message="errors.country"
114
+ style="--input-select-border-focus: var(--gold-04);"
115
+ />
56
116
  ```
57
117
 
118
+ ---
119
+
120
+ ## Class passthrough
121
+
122
+ `InputSelect`'s `:style-class-passthrough` lands on the native `<select class="input-select">`.
123
+ `InputSelectField`'s lands on its root `.input-select-field` only.
124
+
125
+ > **Changed 2026-10-16**: `InputSelectWithLabel` forwarded its passthrough classes to the inner
126
+ > select instead of its own root. If you styled the select through the Field's passthrough, select
127
+ > `.your-class .input-select` instead.
@@ -0,0 +1,198 @@
1
+ <template>
2
+ <div
3
+ class="input-select-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
+ >
10
+ <select
11
+ :id
12
+ v-model="modelValue"
13
+ :aria-invalid="fieldHasError ? 'true' : undefined"
14
+ :class="['input-select', elementClasses]"
15
+ :name
16
+ :title
17
+ :required
18
+ :aria-describedby="ariaDescribedby || undefined"
19
+ @focusin="isActive = true"
20
+ @focusout="isActive = false"
21
+ @change="isDirty = true"
22
+ >
23
+ <option
24
+ v-if="placeholder"
25
+ value=""
26
+ disabled
27
+ :selected="isEmpty"
28
+ class="input-select-option input-select-option-placeholder"
29
+ >
30
+ {{ placeholder }}
31
+ </option>
32
+ <option
33
+ v-for="item in fieldData.data"
34
+ :key="item.id"
35
+ :value="item.value"
36
+ :selected="String(item.value) === String(modelValue)"
37
+ class="input-select-option"
38
+ >
39
+ <Icon
40
+ v-if="item.icon"
41
+ :name="item.icon"
42
+ class="input-select-option-icon"
43
+ aria-hidden="true"
44
+ focusable="false"
45
+ />
46
+ {{ item.label }}
47
+ </option>
48
+ </select>
49
+ </div>
50
+ </template>
51
+
52
+ <script setup lang="ts">
53
+ import type { IFormMultipleOptions, FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
54
+
55
+ interface Props {
56
+ id: string;
57
+ name: string;
58
+ placeholder?: string;
59
+ title?: string;
60
+ required?: boolean;
61
+ fieldHasError?: boolean;
62
+ styleClassPassthrough?: string | string[];
63
+ theme?: FormUiTheme;
64
+ inputVariant?: InputUiVariant;
65
+ ariaDescribedby?: string;
66
+ }
67
+
68
+ const props = withDefaults(defineProps<Props>(), {
69
+ placeholder: "",
70
+ title: "Please select an option",
71
+ required: false,
72
+ fieldHasError: false,
73
+ styleClassPassthrough: () => [],
74
+ theme: "default",
75
+ inputVariant: "normal",
76
+ ariaDescribedby: "",
77
+ });
78
+
79
+ const modelValue = defineModel<string | number | readonly number[]>({ required: true });
80
+ const isDirty = defineModel<boolean>("isDirty");
81
+ const isActive = defineModel<boolean>("isActive");
82
+ const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
83
+
84
+ const isEmpty = computed(() => modelValue.value === "" || modelValue.value == null);
85
+
86
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
87
+
88
+ watch(
89
+ () => props.styleClassPassthrough,
90
+ () => {
91
+ resetElementClasses(props.styleClassPassthrough);
92
+ }
93
+ );
94
+ </script>
95
+
96
+ <style lang="css">
97
+ @layer components {
98
+ .input-select-wrapper {
99
+ --_border: var(--input-select-border, var(--theme-border));
100
+ --_border-focus: var(--input-select-border-focus, var(--theme-border-focus));
101
+
102
+ min-inline-size: 0;
103
+ max-inline-size: 100%;
104
+ background-color: var(--input-select-surface, var(--theme-input-surface));
105
+ overflow: hidden;
106
+
107
+ z-index: 2;
108
+ position: relative;
109
+ transition: all var(--theme-form-transition-duration) ease-in-out;
110
+
111
+ &[data-input-variant="normal"] {
112
+ border: var(--form-element-border-width) solid var(--_border);
113
+ border-radius: var(--form-input-border-radius);
114
+ outline: var(--form-element-outline-width) solid transparent;
115
+ }
116
+
117
+ &[data-input-variant="underlined"] {
118
+ border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
119
+ }
120
+
121
+ &:has(:hover) {
122
+ outline: var(--form-element-outline-width-focus) solid
123
+ var(--input-select-border-hover, var(--theme-border-focus));
124
+ outline-offset: var(--form-element-outline-offset-focus);
125
+ }
126
+
127
+ &:has(select:focus-visible) {
128
+ outline: var(--form-element-outline-width-focus) solid var(--_border-focus);
129
+ outline-offset: var(--form-element-outline-offset-focus);
130
+ }
131
+
132
+ .input-select {
133
+ appearance: none;
134
+ background-color: transparent;
135
+ color: var(--input-select-text-color, var(--theme-input-text-color-normal));
136
+ display: flex;
137
+ align-items: center;
138
+
139
+ &,
140
+ &::picker(select) {
141
+ appearance: base-select;
142
+ }
143
+
144
+ &::picker(select) {
145
+ transition:
146
+ display allow-discrete var(--theme-form-transition-duration),
147
+ opacity var(--theme-form-transition-duration),
148
+ overlay var(--theme-form-transition-duration) allow-discrete;
149
+ }
150
+
151
+ &:not(:open)::picker(select) {
152
+ opacity: 0;
153
+ }
154
+
155
+ &:open::picker(select) {
156
+ opacity: 1;
157
+ border: var(--form-element-border-width) solid var(--_border);
158
+ outline: var(--form-element-outline-width) solid var(--_border-focus);
159
+
160
+ @starting-style {
161
+ opacity: 0;
162
+ }
163
+ }
164
+
165
+ border: none;
166
+ outline: none;
167
+ border-radius: 0;
168
+ width: 100%;
169
+ min-inline-size: 0;
170
+
171
+ font-family: var(--font-family);
172
+ font-size: var(--input-font-size);
173
+ padding-block: var(--input-padding-block);
174
+ padding-inline: var(--input-padding-inline);
175
+ min-height: var(--input-min-height);
176
+
177
+ .input-select-option {
178
+ min-height: var(--input-select-option-min-height);
179
+ gap: var(--input-select-option-gap, 1.5rem);
180
+ overflow-wrap: anywhere;
181
+ white-space: normal;
182
+ transition: all var(--theme-form-transition-duration) ease-in-out;
183
+
184
+ &:hover {
185
+ background-color: var(--input-select-surface-hover, var(--theme-input-surface-hover));
186
+ }
187
+
188
+ .input-select-option-icon {
189
+ display: inline-block;
190
+ flex-shrink: 0;
191
+ width: var(--input-select-option-icon-size, 1.5rem);
192
+ height: var(--input-select-option-icon-size, 1.5rem);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ </style>
@@ -0,0 +1,130 @@
1
+ <template>
2
+ <div
3
+ class="input-select-field"
4
+ :class="elementClasses"
5
+ :data-testid
6
+ :data-theme="theme"
7
+ :data-input-variant="inputVariant"
8
+ :data-invalid="fieldHasError ? '' : null"
9
+ >
10
+ <InputLabel
11
+ :id
12
+ :required
13
+ :input-variant
14
+ :field-has-error
15
+ :style-class-passthrough="['input-select-label']"
16
+ >
17
+ <template #textLabel>{{ label }}</template>
18
+ </InputLabel>
19
+
20
+ <InputDescription
21
+ v-if="inputVariant !== 'outlined'"
22
+ :description-id
23
+ :input-variant
24
+ :field-has-error="fieldHasError"
25
+ :style-class-passthrough="['input-text-description']"
26
+ >
27
+ <template v-if="slots.descriptionHtml" #descriptionHtml>
28
+ <slot name="descriptionHtml"></slot>
29
+ </template>
30
+ <template v-if="slots.descriptionText" #descriptionText>
31
+ <slot name="descriptionText"></slot>
32
+ </template>
33
+ </InputDescription>
34
+
35
+ <InputSelect
36
+ :id
37
+ v-model="modelValue"
38
+ v-model:field-data="fieldData"
39
+ v-model:is-dirty="isDirty"
40
+ v-model:is-active="isActive"
41
+ :name
42
+ :placeholder
43
+ :title
44
+ :field-has-error
45
+ :required
46
+ :theme="selectTheme"
47
+ :aria-describedby="ariaDescribedby()"
48
+ :input-variant
49
+ />
50
+
51
+ <InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="false" :input-variant />
52
+
53
+ <InputDescription
54
+ v-if="inputVariant === 'outlined'"
55
+ :description-id
56
+ :input-variant
57
+ :field-has-error="fieldHasError"
58
+ :style-class-passthrough="['input-text-description']"
59
+ >
60
+ <template v-if="slots.descriptionHtml" #descriptionHtml>
61
+ <slot name="descriptionHtml"></slot>
62
+ </template>
63
+ <template v-if="slots.descriptionText" #descriptionText>
64
+ <slot name="descriptionText"></slot>
65
+ </template>
66
+ </InputDescription>
67
+ </div>
68
+ </template>
69
+
70
+ <script setup lang="ts">
71
+ import type { FormUiTheme, InputUiVariant, IFormMultipleOptions } from "~/types/forms/types.forms";
72
+
73
+ interface Props {
74
+ dataTestid?: string;
75
+ name: string;
76
+ label: string;
77
+ placeholder?: string;
78
+ title?: string;
79
+ errorMessage: object | string;
80
+ required?: boolean;
81
+ fieldHasError?: boolean;
82
+ styleClassPassthrough?: string | string[];
83
+ theme?: FormUiTheme;
84
+ inputVariant?: InputUiVariant;
85
+ }
86
+
87
+ const props = withDefaults(defineProps<Props>(), {
88
+ dataTestid: "input-select-field",
89
+ placeholder: "",
90
+ title: "Please select an option",
91
+ required: false,
92
+ fieldHasError: false,
93
+ styleClassPassthrough: () => [],
94
+ theme: "default",
95
+ inputVariant: "normal",
96
+ });
97
+
98
+ const slots = useSlots();
99
+
100
+ const selectTheme = computed(() => (props.fieldHasError ? "error" : props.theme));
101
+
102
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
103
+
104
+ watch(
105
+ () => props.styleClassPassthrough,
106
+ () => {
107
+ resetElementClasses(props.styleClassPassthrough);
108
+ }
109
+ );
110
+
111
+ const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
112
+ props.name,
113
+ toRef(props, "fieldHasError"),
114
+ slots
115
+ );
116
+
117
+ const modelValue = defineModel<string | number | readonly number[]>({ required: true });
118
+ const isDirty = defineModel<boolean>("isDirty");
119
+ const isActive = defineModel<boolean>("isActive");
120
+ const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
121
+ </script>
122
+
123
+ <style lang="css">
124
+ @layer components {
125
+ .input-select-field {
126
+ min-inline-size: 0;
127
+ overflow-wrap: anywhere;
128
+ }
129
+ }
130
+ </style>