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.
Files changed (58) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +6 -0
  3. package/.claude/skills/component-naming.md +2 -2
  4. package/.claude/skills/components/input-checkbox-radio.md +19 -3
  5. package/.claude/skills/components/input-checkbox.md +14 -3
  6. package/.claude/skills/components/input-description.md +1 -1
  7. package/.claude/skills/components/input-error.md +1 -1
  8. package/.claude/skills/components/input-label.md +2 -2
  9. package/.claude/skills/components/input-number.md +44 -13
  10. package/.claude/skills/components/input-radio.md +15 -1
  11. package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
  12. package/.claude/skills/components/input-textarea-core.md +4 -4
  13. package/.claude/skills/index.md +5 -5
  14. package/.claude/skills/testing-add-unit-test.md +17 -0
  15. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  16. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  17. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  18. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  19. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  20. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  21. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  22. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  23. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  24. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  25. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  26. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  27. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  28. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  29. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  30. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  31. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  32. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  33. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  34. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +2 -2
  35. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +2 -2
  36. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +42 -2
  37. package/app/components/05.forms/input-number/InputNumber.vue +37 -25
  38. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  39. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  40. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  41. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  42. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  43. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  44. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  45. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  46. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  47. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  48. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  49. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  50. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  51. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  52. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  53. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  54. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +5 -5
  55. package/package.json +1 -1
  56. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  57. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  58. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
@@ -1,9 +1,11 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
1
  import { computed, ref } from "vue";
3
- import StorybookComponent from "../InputCheckboxRadioField.vue";
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 InputCheckboxRadioFieldStoryArgs {
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
- export default {
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: StorybookComponent,
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: "Use the labelContent slot (rich label with a link) instead of the label prop",
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: "Sets --input-checkbox-label-color on the story wrapper",
45
- table: { category: "Tokens" },
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
- } as Meta<InputCheckboxRadioFieldStoryArgs>;
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
- const Template: StoryFn<InputCheckboxRadioFieldStoryArgs> = (args) => ({
62
- components: { StorybookComponent },
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 style="margin: 36px; max-width: 480px;" :style="tokenStyles">
74
- <StorybookComponent v-bind="componentArgs" v-model="modelValue">
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&nbsp;<a href="#terms">terms and conditions</a>
77
123
  </template>
78
- </StorybookComponent>
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 = Template.bind({});
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 RichLabel = Template.bind({});
87
- RichLabel.args = { useLabelSlot: true };
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 Underlined = Template.bind({});
90
- Underlined.args = { inputVariant: "underlined" };
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 ErrorState = Template.bind({});
93
- ErrorState.args = { fieldHasError: true, theme: "error" };
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 createWrapper = (props = {}, slots = {}) =>
15
- mountSuspended(InputCheckboxRadio, {
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 createWrapper = (props = {}, slots = {}) =>
16
- mountSuspended(InputCheckboxRadioButton, {
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 createWrapper = (props = {}, slots = {}) =>
13
- mountSuspended(InputCheckboxRadioField, {
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`, `InputRangeDefault`, `InputTextAsNumberWithLabel`, `SingleCheckbox`,
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 `InputRangeDefault`, which has no variants, so it stays `normal` there) |
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`, `InputRangeDefault`, `ToggleSwitchWithLabel`,
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` (`InputRangeDefault` and the toggle switches always pass `normal`) |
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 (no
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
- :class="[inputVariant, { 'has-left-slot': slots.left, 'has-right-slot': slots.right }]"
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 left-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="numeric"
26
- pattern="[0-9]+"
27
+ :inputmode
27
28
  :aria-describedby
28
29
  />
29
30
  </div>
30
- <div v-if="slots.right" class="slot right-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
- const minLength = computed(() => `${props.max.toString().length + 1}em`);
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
- &.normal {
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
- &.underlined {
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: inline-block;
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
- &.has-left-slot {
157
- .left-slot {
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
- &.has-left-slot .left-slot .input-button {
182
+ .input-number-slot-left .input-button {
171
183
  border-right: var(--form-element-border-width) solid var(--_border);
172
184
  }
173
185
 
174
- &.has-right-slot .right-slot .input-button {
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
- :class="[elementClasses, inputVariant, `theme-${theme}`, { error: fieldHasError }]"
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', 'body-normal-bold']"
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="updateValue(-step, Number(modelValue) > min)"
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="updateValue(step, Number(modelValue) < max)"
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 updateValue = (step: number, withinRangeLimit: boolean) => {
130
- if (withinRangeLimit) {
131
- modelValue.value = (Number(modelValue.value) + step) as number;
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>