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.
- package/.claude/rules/consumer/ids-from-useid.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +18 -1
- package/.claude/skills/component-inline-action-button.md +1 -1
- package/.claude/skills/component-local-style-override.md +1 -1
- package/.claude/skills/component-naming.md +13 -13
- package/.claude/skills/components/container-glow.md +2 -2
- package/.claude/skills/components/form-field.md +2 -2
- package/.claude/skills/components/form-wrapper.md +1 -1
- package/.claude/skills/components/glowing-border.md +1 -1
- package/.claude/skills/components/input-checkbox-radio.md +19 -3
- package/.claude/skills/components/input-checkbox.md +14 -3
- package/.claude/skills/components/input-description.md +7 -7
- package/.claude/skills/components/input-error.md +3 -3
- package/.claude/skills/components/input-label.md +7 -8
- package/.claude/skills/components/input-number.md +60 -29
- package/.claude/skills/components/input-radio.md +15 -1
- package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
- package/.claude/skills/components/input-select.md +153 -0
- package/.claude/skills/components/input-text.md +220 -0
- package/.claude/skills/components/input-textarea.md +147 -0
- package/.claude/skills/components/select-menu.md +4 -4
- package/.claude/skills/components/toggle-switch.md +143 -0
- package/.claude/skills/composable-zod-validation.md +1 -1
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/icon-sizing.md +1 -1
- package/.claude/skills/index.md +10 -10
- package/.claude/skills/storybook-add-story.md +1 -1
- package/.claude/skills/testing-add-unit-test.md +17 -0
- package/.claude/skills/theming-component-token-pattern.md +8 -8
- package/.claude/skills/theming-form-geometry-tokens.md +1 -1
- package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
- package/.vscode/srcdev-component-input-range.code-snippets +38 -18
- package/.vscode/srcdev-component-input-select.code-snippets +20 -14
- package/.vscode/srcdev-component-input-text.code-snippets +46 -40
- package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
- package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
- package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
- package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
- package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
- package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
- package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
- package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
- package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
- package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
- package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
- package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
- package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
- package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
- package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
- package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +7 -7
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +6 -6
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +50 -6
- package/app/components/05.forms/input-number/InputNumber.vue +39 -27
- package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
- package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
- package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
- package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
- package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
- package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
- package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
- package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
- package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
- package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
- package/app/components/05.forms/input-range/InputRange.vue +247 -0
- package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
- package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
- package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
- package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
- package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
- package/app/components/05.forms/input-select/InputSelect.vue +198 -0
- package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
- package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
- package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
- package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
- package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
- package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
- package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
- package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
- package/app/components/05.forms/input-text/InputTextField.vue +168 -0
- package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
- package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
- package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
- package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
- package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
- package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
- package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
- package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
- package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
- package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
- package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
- package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
- package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
- package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
- package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +34 -36
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
- package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
- package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
- package/package.json +1 -1
- package/.claude/skills/components/input-select-core.md +0 -151
- package/.claude/skills/components/input-text-core.md +0 -204
- package/.claude/skills/components/input-textarea-core.md +0 -154
- package/.claude/skills/components/toggle-switch-core.md +0 -169
- package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
- package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
- package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
- package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
- package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
- package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
- package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
- package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
- package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
- package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
- package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
- package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
- package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
- package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
- package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
|
@@ -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 "../
|
|
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("
|
|
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
|
|
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-
|
|
45
|
-
expect(wrapper.find(".input-range-
|
|
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-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 |
|
|
15
|
+
| Token | Default | Controls |
|
|
16
16
|
|---|---|---|
|
|
17
17
|
| `--input-select-surface` | `var(--theme-input-surface)` | Wrapper/select background |
|
|
18
|
-
| `--input-select-
|
|
19
|
-
| `--input-select-border` | `var(--theme-border)` | Border colour (
|
|
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
|
-
<
|
|
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>
|