srcdev-nuxt-components 9.6.7 → 9.6.8
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 +6 -0
- package/.claude/skills/component-naming.md +2 -2
- 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 +1 -1
- package/.claude/skills/components/input-error.md +1 -1
- package/.claude/skills/components/input-label.md +2 -2
- package/.claude/skills/components/input-number.md +44 -13
- 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-textarea-core.md +4 -4
- package/.claude/skills/index.md +5 -5
- package/.claude/skills/testing-add-unit-test.md +17 -0
- package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
- package/.vscode/srcdev-component-input-range.code-snippets +38 -18
- 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 +2 -2
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +42 -2
- package/app/components/05.forms/input-number/InputNumber.vue +37 -25
- 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/patterns/stories/MigratedFieldsForm.stories.ts +5 -5
- package/package.json +1 -1
- 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-checkbox-radio/stories/InputCheckboxRadioField.stories.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
1
|
import { computed, ref } from "vue";
|
|
3
|
-
import
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import InputCheckboxRadioField from "../InputCheckboxRadioField.vue";
|
|
4
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
4
5
|
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
5
7
|
|
|
6
|
-
interface
|
|
8
|
+
interface StoryArgs {
|
|
7
9
|
type: "checkbox" | "radio";
|
|
8
10
|
name: string;
|
|
9
11
|
label: string;
|
|
@@ -15,9 +17,22 @@ interface InputCheckboxRadioFieldStoryArgs {
|
|
|
15
17
|
useLabelSlot: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
// Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
|
|
21
|
+
const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
|
|
22
|
+
const parentNote = `
|
|
23
|
+
<p style="${storyNoteStyle}">
|
|
24
|
+
Wrapped in a fieldset with a legend, description and error message, this is the
|
|
25
|
+
<a href="/?path=/story/components-forms-input-checkbox-singlecheckbox--default" target="_top">SingleCheckbox</a>;
|
|
26
|
+
a data-driven group of them is
|
|
27
|
+
<a href="/?path=/story/components-forms-input-checkbox-multiplecheckboxes--default" target="_top">MultipleCheckboxes</a>
|
|
28
|
+
or
|
|
29
|
+
<a href="/?path=/story/components-forms-input-radio-multipleradiobuttons--default" target="_top">MultipleRadiobuttons</a>.
|
|
30
|
+
</p>
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const meta: Meta<StoryArgs> = {
|
|
19
34
|
title: "Components/Forms/Input Checkbox Radio/InputCheckboxRadioField",
|
|
20
|
-
component:
|
|
35
|
+
component: InputCheckboxRadioField,
|
|
21
36
|
argTypes: {
|
|
22
37
|
type: { control: { type: "select" }, options: ["checkbox", "radio"], table: { category: "Basic" } },
|
|
23
38
|
name: { control: "text", table: { category: "Basic" } },
|
|
@@ -25,7 +40,7 @@ export default {
|
|
|
25
40
|
required: { control: "boolean", table: { category: "Basic" } },
|
|
26
41
|
useLabelSlot: {
|
|
27
42
|
control: "boolean",
|
|
28
|
-
description: "
|
|
43
|
+
description: "Story-only: use the labelContent slot (rich label with a link) instead of the label prop",
|
|
29
44
|
table: { category: "Slots" },
|
|
30
45
|
},
|
|
31
46
|
fieldHasError: { control: "boolean", description: "Rendered as data-invalid", table: { category: "States" } },
|
|
@@ -41,8 +56,9 @@ export default {
|
|
|
41
56
|
},
|
|
42
57
|
labelColor: {
|
|
43
58
|
control: "color",
|
|
44
|
-
description:
|
|
45
|
-
|
|
59
|
+
description:
|
|
60
|
+
"**Story control, not a prop.** Sets `--input-checkbox-label-color` on the story wrapper. In your CSS: `.my-form { --input-checkbox-label-color: #333; }`",
|
|
61
|
+
table: { category: "CSS tokens (story only, set in your CSS)" },
|
|
46
62
|
},
|
|
47
63
|
},
|
|
48
64
|
args: {
|
|
@@ -56,10 +72,39 @@ export default {
|
|
|
56
72
|
labelColor: "",
|
|
57
73
|
useLabelSlot: false,
|
|
58
74
|
},
|
|
59
|
-
|
|
75
|
+
decorators: [
|
|
76
|
+
(story, context) => ({
|
|
77
|
+
components: { story, CanvasSwitcher },
|
|
78
|
+
setup() {
|
|
79
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
80
|
+
return { canvasName };
|
|
81
|
+
},
|
|
82
|
+
template: `
|
|
83
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
84
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
85
|
+
</div>
|
|
86
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
87
|
+
<story />
|
|
88
|
+
</div>
|
|
89
|
+
`,
|
|
90
|
+
}),
|
|
91
|
+
],
|
|
92
|
+
parameters: {
|
|
93
|
+
layout: "fullscreen",
|
|
94
|
+
docs: {
|
|
95
|
+
description: {
|
|
96
|
+
component:
|
|
97
|
+
"A `<label>` holding the checkbox or radio control and its text, so the whole row is clickable. It generates its own id with `useId()`. Props: `type`, `name`, `label`, `trueValue`/`falseValue`, `multipleOptions` (array model for a checkbox group), `required`, `fieldHasError`. Slots: `labelContent` (rich label), `checkedIcon`. Usually used through `SingleCheckbox`, `MultipleCheckboxes` or `MultipleRadiobuttons`: see the [SingleCheckbox](?path=/story/components-forms-input-checkbox-singlecheckbox--default) story.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
60
102
|
|
|
61
|
-
|
|
62
|
-
|
|
103
|
+
export default meta;
|
|
104
|
+
type Story = StoryObj<StoryArgs>;
|
|
105
|
+
|
|
106
|
+
const render = (args: StoryArgs) => ({
|
|
107
|
+
components: { InputCheckboxRadioField },
|
|
63
108
|
setup() {
|
|
64
109
|
const modelValue = ref<boolean | string>(false);
|
|
65
110
|
const componentArgs = computed(() => {
|
|
@@ -70,24 +115,52 @@ const Template: StoryFn<InputCheckboxRadioFieldStoryArgs> = (args) => ({
|
|
|
70
115
|
return { args, componentArgs, tokenStyles, modelValue };
|
|
71
116
|
},
|
|
72
117
|
template: `
|
|
73
|
-
<div
|
|
74
|
-
|
|
118
|
+
<div :style="tokenStyles">
|
|
119
|
+
${parentNote}
|
|
120
|
+
<InputCheckboxRadioField v-bind="componentArgs" v-model="modelValue">
|
|
75
121
|
<template v-if="args.useLabelSlot" #labelContent>
|
|
76
122
|
I agree to the <a href="#terms">terms and conditions</a>
|
|
77
123
|
</template>
|
|
78
|
-
</
|
|
124
|
+
</InputCheckboxRadioField>
|
|
79
125
|
<p style="margin-top: 2rem; font-family: monospace;">modelValue: {{ JSON.stringify(modelValue) }}</p>
|
|
80
126
|
</div>
|
|
81
127
|
`,
|
|
82
128
|
});
|
|
83
129
|
|
|
84
|
-
export const Checkbox =
|
|
130
|
+
export const Checkbox: Story = { render };
|
|
131
|
+
|
|
132
|
+
export const RichLabel: Story = {
|
|
133
|
+
args: { useLabelSlot: true },
|
|
134
|
+
render,
|
|
135
|
+
parameters: { docs: { description: { story: "The `labelContent` slot replaces the `label` prop, here with a link." } } },
|
|
136
|
+
};
|
|
85
137
|
|
|
86
|
-
export const
|
|
87
|
-
|
|
138
|
+
export const Underlined: Story = {
|
|
139
|
+
args: { inputVariant: "underlined" },
|
|
140
|
+
render,
|
|
141
|
+
parameters: { docs: { description: { story: "`inputVariant=\"underlined\"` squares off the checkbox." } } },
|
|
142
|
+
};
|
|
88
143
|
|
|
89
|
-
export const
|
|
90
|
-
|
|
144
|
+
export const ErrorState: Story = {
|
|
145
|
+
args: { fieldHasError: true, theme: "error" },
|
|
146
|
+
render,
|
|
147
|
+
parameters: { docs: { description: { story: "`fieldHasError` sets `data-invalid` on the label and `aria-invalid` on the input." } } },
|
|
148
|
+
};
|
|
91
149
|
|
|
92
|
-
export const
|
|
93
|
-
|
|
150
|
+
export const StressTest: Story = {
|
|
151
|
+
name: "Stress Test (Worst-Case Data)",
|
|
152
|
+
args: {
|
|
153
|
+
label:
|
|
154
|
+
"💇♀️ Datenschutzgrundverordnungseinwilligungsbestätigungserklärung https://example.com/privacy/policy/with/a/very/long/unbroken/path <b>not bold</b> قص الشعر",
|
|
155
|
+
},
|
|
156
|
+
render,
|
|
157
|
+
parameters: {
|
|
158
|
+
initialCanvas: "mobileCanvas",
|
|
159
|
+
docs: {
|
|
160
|
+
description: {
|
|
161
|
+
story:
|
|
162
|
+
"A label with emoji at the start, a long unbroken German word and URL, HTML-like text and right-to-left text. Check at every canvas width: the label wraps inside the dashed outline, the control keeps its size instead of being squashed, markup renders as text, and clicking anywhere on the label toggles the control. Also try an empty label and a single character, and switch `type` to `radio`.",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
2
3
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
4
|
import InputCheckboxRadio from "../InputCheckboxRadio.vue";
|
|
4
5
|
|
|
@@ -11,12 +12,22 @@ const defaultProps = {
|
|
|
11
12
|
modelValue: "unchecked",
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
const
|
|
15
|
-
|
|
15
|
+
const mounted: VueWrapper[] = [];
|
|
16
|
+
|
|
17
|
+
const createWrapper = async (props = {}, slots = {}) => {
|
|
18
|
+
const wrapper = await mountSuspended(InputCheckboxRadio, {
|
|
16
19
|
attachTo: document.body,
|
|
17
20
|
props: { ...defaultProps, ...props },
|
|
18
21
|
slots,
|
|
19
22
|
});
|
|
23
|
+
mounted.push(wrapper);
|
|
24
|
+
return wrapper;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
mounted.splice(0).forEach((wrapper) => wrapper.unmount());
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
20
31
|
|
|
21
32
|
const inputOf = (wrapper: Awaited<ReturnType<typeof createWrapper>>) => wrapper.find("input").element as HTMLInputElement;
|
|
22
33
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { defineComponent } from "vue";
|
|
3
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
2
4
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
5
|
import InputCheckboxRadioButton from "../InputCheckboxRadioButton.vue";
|
|
4
6
|
|
|
@@ -12,12 +14,22 @@ const defaultProps = {
|
|
|
12
14
|
multipleOptions: true,
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
+
const mounted: VueWrapper[] = [];
|
|
18
|
+
|
|
19
|
+
const createWrapper = async (props = {}, slots = {}) => {
|
|
20
|
+
const wrapper = await mountSuspended(InputCheckboxRadioButton, {
|
|
17
21
|
attachTo: document.body,
|
|
18
22
|
props: { ...defaultProps, ...props },
|
|
19
23
|
slots,
|
|
20
24
|
});
|
|
25
|
+
mounted.push(wrapper);
|
|
26
|
+
return wrapper;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
mounted.splice(0).forEach((wrapper) => wrapper.unmount());
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
21
33
|
|
|
22
34
|
describe("InputCheckboxRadioButton", () => {
|
|
23
35
|
it("renders a label linked to the inner control by id", async () => {
|
|
@@ -27,6 +39,29 @@ describe("InputCheckboxRadioButton", () => {
|
|
|
27
39
|
expect(wrapper.find("input").attributes("id")).toBe("service-balayage");
|
|
28
40
|
});
|
|
29
41
|
|
|
42
|
+
it("generates a space-free id linking label and control when no id is passed", async () => {
|
|
43
|
+
const wrapper = await createWrapper({ id: undefined });
|
|
44
|
+
const id = wrapper.find("input").attributes("id");
|
|
45
|
+
|
|
46
|
+
expect(id).toBeTruthy();
|
|
47
|
+
expect(id).not.toMatch(/\s/);
|
|
48
|
+
expect(wrapper.find("label.input-checkbox-radio-button").attributes("for")).toBe(id);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("generates a different id for each instance", async () => {
|
|
52
|
+
const Host = defineComponent({
|
|
53
|
+
components: { InputCheckboxRadioButton },
|
|
54
|
+
setup: () => ({ props: { ...defaultProps, id: undefined } }),
|
|
55
|
+
template: `<div><InputCheckboxRadioButton v-bind="props" /><InputCheckboxRadioButton v-bind="props" /></div>`,
|
|
56
|
+
});
|
|
57
|
+
const host = await mountSuspended(Host);
|
|
58
|
+
mounted.push(host);
|
|
59
|
+
const ids = host.findAll("input").map((input) => input.attributes("id"));
|
|
60
|
+
|
|
61
|
+
expect(ids).toHaveLength(2);
|
|
62
|
+
expect(ids[0]).not.toBe(ids[1]);
|
|
63
|
+
});
|
|
64
|
+
|
|
30
65
|
it("renders the label prop, or the labelContent slot in its place", async () => {
|
|
31
66
|
const plain = await createWrapper();
|
|
32
67
|
expect(plain.find(".input-checkbox-radio-button-label").text()).toBe("Balayage");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
2
3
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
4
|
import InputCheckboxRadioField from "../InputCheckboxRadioField.vue";
|
|
4
5
|
|
|
@@ -9,12 +10,22 @@ const defaultProps = {
|
|
|
9
10
|
modelValue: false,
|
|
10
11
|
};
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
const mounted: VueWrapper[] = [];
|
|
14
|
+
|
|
15
|
+
const createWrapper = async (props = {}, slots = {}) => {
|
|
16
|
+
const wrapper = await mountSuspended(InputCheckboxRadioField, {
|
|
14
17
|
attachTo: document.body,
|
|
15
18
|
props: { ...defaultProps, ...props },
|
|
16
19
|
slots,
|
|
17
20
|
});
|
|
21
|
+
mounted.push(wrapper);
|
|
22
|
+
return wrapper;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
mounted.splice(0).forEach((wrapper) => wrapper.unmount());
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
18
29
|
|
|
19
30
|
describe("InputCheckboxRadioField", () => {
|
|
20
31
|
it("renders a label linked to the inner control", async () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`InputDescription` is the help text rendered between the label and the control by every `*Field`
|
|
4
4
|
wrapper (`InputTextWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
|
|
5
|
-
`InputTextareaWithLabel`, `
|
|
5
|
+
`InputTextareaWithLabel`, `InputRangeField`, `InputTextAsNumberWithLabel`, `SingleCheckbox`,
|
|
6
6
|
`MultipleCheckboxes`, `MultipleRadiobuttons`) when you pass it a `descriptionText` or
|
|
7
7
|
`descriptionHtml` slot. With neither slot it renders nothing.
|
|
8
8
|
|
|
@@ -53,7 +53,7 @@ No private tokens.
|
|
|
53
53
|
|
|
54
54
|
| Attribute on `.input-description` | When |
|
|
55
55
|
|---|---|
|
|
56
|
-
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (not passed by `
|
|
56
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (not passed by `InputRangeField`, which has no variants, so it stays `normal` there) |
|
|
57
57
|
| `data-invalid` | The field has an error |
|
|
58
58
|
|
|
59
59
|
Inner elements: `.input-description-html` (div), `.input-description-text` (p).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`InputLabel` is the `<label>` rendered by every labelled field wrapper (`InputTextWithLabel`,
|
|
4
4
|
`InputTextAsNumberWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
|
|
5
|
-
`InputTextareaWithLabel`, `
|
|
5
|
+
`InputTextareaWithLabel`, `InputRangeField`, `ToggleSwitchWithLabel`,
|
|
6
6
|
`ToggleSwitchWithLabelInline`).
|
|
7
7
|
|
|
8
8
|
## Public token API
|
|
@@ -50,7 +50,7 @@ shared fallback so the per-state tokens don't repeat it.
|
|
|
50
50
|
|
|
51
51
|
| Attribute on `.input-label` | When |
|
|
52
52
|
|---|---|
|
|
53
|
-
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (`
|
|
53
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (`InputRangeField` and the toggle switches always pass `normal`) |
|
|
54
54
|
| `data-invalid` | The field has an error (not passed by `ToggleSwitchWithLabelInline`) |
|
|
55
55
|
|
|
56
56
|
| Attribute on `.input-label-indicator` | When |
|
|
@@ -29,12 +29,41 @@ component and untouched by this — see `theming-form-geometry-tokens.md`.
|
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
+
## State hooks
|
|
33
|
+
|
|
34
|
+
| Attribute | On | When |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-number-wrapper`, `.input-number-field` | Always, mirrors `input-variant` |
|
|
37
|
+
| `data-theme` | `.input-number-wrapper`, `.input-number-field` | Always, mirrors `theme` |
|
|
38
|
+
| `data-invalid` | `.input-number-wrapper`, `.input-number-field` | `field-has-error` |
|
|
39
|
+
| `data-has-left-slot` / `data-has-right-slot` | `.input-number-wrapper` | The `left` / `right` slot is filled |
|
|
40
|
+
|
|
41
|
+
Inner elements: `.input-number-slot` (both slot holders), `.input-number-slot-left`,
|
|
42
|
+
`.input-number-slot-right`, `.input-number-container`, `.input-number` (the native input),
|
|
43
|
+
`.input-number-label` (the Field's `InputLabel`).
|
|
44
|
+
|
|
45
|
+
> **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
|
|
46
|
+
> (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
|
|
47
|
+
> above instead:
|
|
48
|
+
>
|
|
49
|
+
> - `.input-number-wrapper.normal` / `.underlined` → `[data-input-variant="…"]`
|
|
50
|
+
> - `.input-number-wrapper.has-left-slot` / `.has-right-slot` → `[data-has-left-slot]` /
|
|
51
|
+
> `[data-has-right-slot]`
|
|
52
|
+
> - `.slot.left-slot` / `.slot.right-slot` → `.input-number-slot-left` / `.input-number-slot-right`
|
|
53
|
+
> - `.input-number-field.error` → `[data-invalid]`; its `.normal`/`.underlined` and `theme-*`
|
|
54
|
+
> classes → `[data-input-variant]` / `[data-theme]`
|
|
55
|
+
>
|
|
56
|
+
> Also: long label, description and error copy now wraps (`overflow-wrap: anywhere` on
|
|
57
|
+
> `.input-number-field`), and the control's minimum width is capped at `12em` so a long `min`/`max`
|
|
58
|
+
> can't push it past a phone screen.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
32
62
|
## Input variant
|
|
33
63
|
|
|
34
64
|
`:input-variant="'normal' | 'outlined' | 'underlined'"` (default `"normal"`) — same prop as
|
|
35
65
|
`InputTextCore`. Only `normal` (bordered box, with hover/focus outline) and `underlined`
|
|
36
|
-
(bottom-border only) have CSS today; `outlined` has no styling in `InputTextCore` either
|
|
37
|
-
`&.outlined` rule exists there), so passing it renders with no border/outline at all — a
|
|
66
|
+
(bottom-border only) have CSS today; `outlined` has no styling in `InputTextCore` either, so passing it renders with no border/outline at all — a
|
|
38
67
|
pre-existing gap in the type, not something specific to `InputNumber`. Declared for prop-shape
|
|
39
68
|
consistency with the rest of `05.forms`.
|
|
40
69
|
|
|
@@ -68,3 +97,14 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
68
97
|
<InputNumber id="quantity" name="quantity" :min="1" :max="10" style="--input-number-border: var(--gold-04);" />
|
|
69
98
|
```
|
|
70
99
|
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Class passthrough
|
|
104
|
+
|
|
105
|
+
`InputNumber`'s `:style-class-passthrough` lands on the native `<input class="input-number">`.
|
|
106
|
+
`InputNumberField`'s lands on its root `.input-number-field` only.
|
|
107
|
+
|
|
108
|
+
> **Changed 2026-10-16**: `InputNumberField` also forwarded its passthrough classes to the inner
|
|
109
|
+
> input, so a class meant for the field matched two elements. If you styled the input through the
|
|
110
|
+
> Field's passthrough, select `.your-class .input-number` instead.
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
class="input-number-wrapper"
|
|
4
4
|
:data-theme="theme"
|
|
5
5
|
:data-invalid="fieldHasError ? '' : null"
|
|
6
|
-
:
|
|
6
|
+
:data-input-variant="inputVariant"
|
|
7
|
+
:data-has-left-slot="slots.left ? '' : undefined"
|
|
8
|
+
:data-has-right-slot="slots.right ? '' : undefined"
|
|
7
9
|
>
|
|
8
|
-
<div v-if="slots.left" class="slot
|
|
10
|
+
<div v-if="slots.left" class="input-number-slot input-number-slot-left">
|
|
9
11
|
<slot name="left"></slot>
|
|
10
12
|
</div>
|
|
11
13
|
|
|
@@ -22,12 +24,11 @@
|
|
|
22
24
|
:step
|
|
23
25
|
:placeholder
|
|
24
26
|
:class="['input-number', `input-number--${weight}`, elementClasses]"
|
|
25
|
-
inputmode
|
|
26
|
-
pattern="[0-9]+"
|
|
27
|
+
:inputmode
|
|
27
28
|
:aria-describedby
|
|
28
29
|
/>
|
|
29
30
|
</div>
|
|
30
|
-
<div v-if="slots.right" class="slot
|
|
31
|
+
<div v-if="slots.right" class="input-number-slot input-number-slot-right">
|
|
31
32
|
<slot name="right"></slot>
|
|
32
33
|
</div>
|
|
33
34
|
</div>
|
|
@@ -70,8 +71,26 @@ const modelValue = defineModel<number | readonly number[]>({
|
|
|
70
71
|
required: true,
|
|
71
72
|
});
|
|
72
73
|
|
|
73
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
74
|
-
|
|
74
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
75
|
+
|
|
76
|
+
watch(
|
|
77
|
+
() => props.styleClassPassthrough,
|
|
78
|
+
() => {
|
|
79
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const decimalPlaces = (value: number) => (String(value).split(".")[1] ?? "").length;
|
|
84
|
+
|
|
85
|
+
const minLength = computed(() => {
|
|
86
|
+
const longest = Math.max(String(props.min).length, String(props.max).length) + decimalPlaces(props.step);
|
|
87
|
+
return `${Math.min(longest + 1, 12)}em`;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const inputmode = computed(() => {
|
|
91
|
+
if (props.min < 0) return undefined;
|
|
92
|
+
return Number.isInteger(props.step) ? "numeric" : "decimal";
|
|
93
|
+
});
|
|
75
94
|
</script>
|
|
76
95
|
|
|
77
96
|
<style lang="css">
|
|
@@ -92,6 +111,7 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
92
111
|
align-items: center;
|
|
93
112
|
|
|
94
113
|
width: fit-content;
|
|
114
|
+
max-inline-size: 100%;
|
|
95
115
|
|
|
96
116
|
background-color: var(--_surface);
|
|
97
117
|
overflow: hidden;
|
|
@@ -99,7 +119,7 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
99
119
|
position: relative;
|
|
100
120
|
z-index: 2;
|
|
101
121
|
|
|
102
|
-
|
|
122
|
+
&[data-input-variant="normal"] {
|
|
103
123
|
border: var(--form-element-border-width) solid var(--_border);
|
|
104
124
|
border-radius: var(--form-input-border-radius);
|
|
105
125
|
outline: var(--form-element-outline-width) solid transparent;
|
|
@@ -116,12 +136,14 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
116
136
|
}
|
|
117
137
|
}
|
|
118
138
|
|
|
119
|
-
|
|
139
|
+
&[data-input-variant="underlined"] {
|
|
120
140
|
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
|
|
121
141
|
}
|
|
122
142
|
|
|
123
|
-
.slot {
|
|
124
|
-
display:
|
|
143
|
+
.input-number-slot {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
flex-shrink: 0;
|
|
125
147
|
|
|
126
148
|
.icon {
|
|
127
149
|
font-weight: 900;
|
|
@@ -153,25 +175,15 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
153
175
|
}
|
|
154
176
|
}
|
|
155
177
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
display: flex;
|
|
159
|
-
align-items: center;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
&.has-right-slot {
|
|
164
|
-
.right-slot {
|
|
165
|
-
display: flex;
|
|
166
|
-
align-items: center;
|
|
167
|
-
}
|
|
178
|
+
.input-number-container {
|
|
179
|
+
min-inline-size: 0;
|
|
168
180
|
}
|
|
169
181
|
|
|
170
|
-
|
|
182
|
+
.input-number-slot-left .input-button {
|
|
171
183
|
border-right: var(--form-element-border-width) solid var(--_border);
|
|
172
184
|
}
|
|
173
185
|
|
|
174
|
-
|
|
186
|
+
.input-number-slot-right .input-button {
|
|
175
187
|
border-left: var(--form-element-border-width) solid var(--_border);
|
|
176
188
|
}
|
|
177
189
|
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
class="input-number-field"
|
|
4
4
|
:data-theme="theme"
|
|
5
5
|
:data-invalid="fieldHasError ? '' : null"
|
|
6
|
-
:
|
|
6
|
+
:data-input-variant="inputVariant"
|
|
7
|
+
:class="elementClasses"
|
|
7
8
|
>
|
|
8
9
|
<InputLabel
|
|
9
10
|
:id
|
|
10
11
|
:required
|
|
11
12
|
:input-variant
|
|
12
13
|
:field-has-error
|
|
13
|
-
:style-class-passthrough="['input-number-label'
|
|
14
|
+
:style-class-passthrough="['input-number-label']"
|
|
14
15
|
>
|
|
15
16
|
<template #textLabel>{{ label }}</template>
|
|
16
17
|
</InputLabel>
|
|
@@ -42,7 +43,6 @@
|
|
|
42
43
|
:weight
|
|
43
44
|
:input-variant
|
|
44
45
|
:field-has-error
|
|
45
|
-
:style-class-passthrough
|
|
46
46
|
:aria-describedby="ariaDescribedby()"
|
|
47
47
|
>
|
|
48
48
|
<template v-if="slots.left" #left>
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
:button-text="stepDownLabel"
|
|
54
54
|
:theme="theme"
|
|
55
55
|
variant="inline"
|
|
56
|
-
@click.stop.prevent="
|
|
56
|
+
@click.stop.prevent="stepBy(-1)"
|
|
57
57
|
>
|
|
58
58
|
<template #iconOnly>
|
|
59
59
|
<slot name="left"></slot>
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
:button-text="stepUpLabel"
|
|
69
69
|
:theme="theme"
|
|
70
70
|
variant="inline"
|
|
71
|
-
@click.stop.prevent="
|
|
71
|
+
@click.stop.prevent="stepBy(1)"
|
|
72
72
|
>
|
|
73
73
|
<template #iconOnly>
|
|
74
74
|
<slot name="right"></slot>
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
</InputButton>
|
|
77
77
|
</template>
|
|
78
78
|
</InputNumber>
|
|
79
|
-
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" />
|
|
79
|
+
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" :input-variant />
|
|
80
80
|
</div>
|
|
81
81
|
</template>
|
|
82
82
|
|
|
@@ -115,7 +115,14 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
const slots = useSlots();
|
|
118
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
118
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
119
|
+
|
|
120
|
+
watch(
|
|
121
|
+
() => props.styleClassPassthrough,
|
|
122
|
+
() => {
|
|
123
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
124
|
+
}
|
|
125
|
+
);
|
|
119
126
|
|
|
120
127
|
const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
|
|
121
128
|
props.name,
|
|
@@ -126,16 +133,28 @@ const modelValue = defineModel<number | readonly number[]>({
|
|
|
126
133
|
required: true,
|
|
127
134
|
});
|
|
128
135
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
const decimalPlaces = (value: number) => (String(value).split(".")[1] ?? "").length;
|
|
137
|
+
const clamp = (value: number) => Math.min(Math.max(value, props.min), props.max);
|
|
138
|
+
|
|
139
|
+
const stepBy = (direction: 1 | -1) => {
|
|
140
|
+
const current = modelValue.value;
|
|
141
|
+
if (typeof current !== "number" || !Number.isFinite(current)) {
|
|
142
|
+
modelValue.value = clamp(0);
|
|
143
|
+
return;
|
|
132
144
|
}
|
|
145
|
+
const stepSize = Math.abs(props.step) || 1;
|
|
146
|
+
const precision = Math.max(decimalPlaces(stepSize), decimalPlaces(current), decimalPlaces(props.min));
|
|
147
|
+
const next = clamp(Number((current + direction * stepSize).toFixed(precision)));
|
|
148
|
+
if (next !== current) modelValue.value = next;
|
|
133
149
|
};
|
|
134
150
|
</script>
|
|
135
151
|
|
|
136
152
|
<style lang="css">
|
|
137
153
|
@layer components {
|
|
138
154
|
.input-number-field {
|
|
155
|
+
min-inline-size: 0;
|
|
156
|
+
overflow-wrap: anywhere;
|
|
157
|
+
|
|
139
158
|
.input-number-label {
|
|
140
159
|
display: block;
|
|
141
160
|
margin-block: 0.8rem;
|
|
@@ -144,12 +163,6 @@ const updateValue = (step: number, withinRangeLimit: boolean) => {
|
|
|
144
163
|
cursor: pointer;
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
|
-
|
|
148
|
-
.label-description {
|
|
149
|
-
font-family: var(--font-family);
|
|
150
|
-
font-size: 1.6rem;
|
|
151
|
-
margin-top: 1.2rem;
|
|
152
|
-
}
|
|
153
166
|
}
|
|
154
167
|
}
|
|
155
168
|
</style>
|