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,8 +1,10 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
1
  import { ref } from "vue";
3
- import StorybookComponent from "../InputNumber.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputNumber from "../InputNumber.vue";
4
+ import InputButton from "../../input-button/InputButton.vue";
5
+ import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
4
6
 
5
- interface InputNumberStoryArgs {
7
+ interface StoryArgs {
6
8
  id: string;
7
9
  name: string;
8
10
  min: number;
@@ -11,13 +13,23 @@ interface InputNumberStoryArgs {
11
13
  placeholder: string;
12
14
  required: boolean;
13
15
  fieldHasError: boolean;
16
+ theme: FormUiTheme;
14
17
  weight: string;
15
- inputVariant: string;
18
+ inputVariant: InputUiVariant;
16
19
  }
17
20
 
18
- export default {
21
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
22
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
23
+ const parentNote = `
24
+ <p style="${storyNoteStyle}">
25
+ With a label, description, error message and clamped step buttons wired up, this is the
26
+ <a href="/?path=/story/components-forms-input-number-inputnumberfield--with-step-buttons" target="_top">InputNumberField</a>.
27
+ </p>
28
+ `;
29
+
30
+ const meta: Meta<StoryArgs> = {
19
31
  title: "Components/Forms/Input Number/InputNumber",
20
- component: StorybookComponent,
32
+ component: InputNumber,
21
33
  argTypes: {
22
34
  id: { control: "text", table: { category: "Basic" } },
23
35
  name: { control: "text", table: { category: "Basic" } },
@@ -27,7 +39,12 @@ export default {
27
39
  placeholder: { control: "text", table: { category: "Basic" } },
28
40
  required: { control: "boolean", table: { category: "Basic" } },
29
41
  fieldHasError: { control: "boolean", table: { category: "States" } },
30
- weight: { control: "text", table: { category: "Styling" } },
42
+ theme: {
43
+ control: { type: "select" },
44
+ options: ["default", "success", "error", "warning"],
45
+ table: { category: "Styling" },
46
+ },
47
+ weight: { control: "text", description: "Adds an `input-number--{weight}` class; nothing styles it yet", table: { category: "Styling" } },
31
48
  inputVariant: {
32
49
  control: { type: "select" },
33
50
  options: ["normal", "outlined", "underlined"],
@@ -43,50 +60,84 @@ export default {
43
60
  placeholder: "",
44
61
  required: false,
45
62
  fieldHasError: false,
63
+ theme: "default",
46
64
  weight: "normal",
47
65
  inputVariant: "normal",
48
66
  },
49
- } as Meta<InputNumberStoryArgs>;
67
+ parameters: {
68
+ docs: {
69
+ description: {
70
+ component:
71
+ "The bare native `<input type=\"number\">` with `left`/`right` slots for embedded step buttons. No label, description or error: that's `InputNumberField`. `inputmode` follows `min` and `step` (`numeric`, `decimal`, or none when negatives are allowed). Hooks: `data-input-variant`, `data-theme`, `data-invalid`, `data-has-left-slot`, `data-has-right-slot`.",
72
+ },
73
+ },
74
+ },
75
+ };
76
+
77
+ export default meta;
78
+ type Story = StoryObj<StoryArgs>;
50
79
 
51
- const Template: StoryFn<InputNumberStoryArgs> = (args) => ({
52
- components: { StorybookComponent },
80
+ const render = (args: StoryArgs) => ({
81
+ components: { InputNumber },
53
82
  setup() {
54
83
  const modelValue = ref(50);
55
84
  return { args, modelValue };
56
85
  },
57
86
  template: `
58
87
  <div style="margin: 36px; max-width: 320px;">
59
- <StorybookComponent v-bind="args" v-model="modelValue" />
88
+ ${parentNote}
89
+ <InputNumber v-bind="args" v-model="modelValue" />
60
90
  </div>
61
91
  `,
62
92
  });
63
93
 
64
- export const Default = Template.bind({});
65
-
66
- export const ErrorState = Template.bind({});
67
- ErrorState.args = { fieldHasError: true };
68
-
69
- const WithStepButtonsTemplate: StoryFn<InputNumberStoryArgs> = (args) => ({
70
- components: { StorybookComponent },
94
+ const renderWithSlots = (args: StoryArgs) => ({
95
+ components: { InputNumber, InputButton },
71
96
  setup() {
72
97
  const modelValue = ref(50);
73
98
  return { args, modelValue };
74
99
  },
75
100
  template: `
76
101
  <div style="margin: 36px; max-width: 320px;">
77
- <StorybookComponent v-bind="args" v-model="modelValue">
102
+ ${parentNote}
103
+ <InputNumber v-bind="args" v-model="modelValue">
78
104
  <template #left>
79
- <button type="button" @click="modelValue = Math.max(args.min, modelValue - args.step)">−</button>
105
+ <InputButton type="button" button-text="Step down" variant="inline" @click="modelValue = Math.max(args.min, modelValue - args.step)">
106
+ <template #iconOnly><Icon name="mdi:minus" /></template>
107
+ </InputButton>
80
108
  </template>
81
109
  <template #right>
82
- <button type="button" @click="modelValue = Math.min(args.max, modelValue + args.step)">+</button>
110
+ <InputButton type="button" button-text="Step up" variant="inline" @click="modelValue = Math.min(args.max, modelValue + args.step)">
111
+ <template #iconOnly><Icon name="mdi:plus" /></template>
112
+ </InputButton>
83
113
  </template>
84
- </StorybookComponent>
114
+ </InputNumber>
85
115
  </div>
86
116
  `,
87
117
  });
88
118
 
89
- export const WithStepButtons = WithStepButtonsTemplate.bind({});
119
+ export const Default: Story = { render };
120
+
121
+ export const ErrorState: Story = {
122
+ args: { fieldHasError: true },
123
+ render,
124
+ parameters: { docs: { description: { story: "`fieldHasError` sets `data-invalid` on the wrapper." } } },
125
+ };
126
+
127
+ export const WithStepButtons: Story = {
128
+ render: renderWithSlots,
129
+ parameters: {
130
+ docs: {
131
+ description: {
132
+ story:
133
+ "Inline `InputButton`s in the `left`/`right` slots become flush segments of the control. The wiring here is deliberately minimal; `InputNumberField` does it properly (clamping, rounding, empty field).",
134
+ },
135
+ },
136
+ },
137
+ };
90
138
 
91
- export const Underlined = WithStepButtonsTemplate.bind({});
92
- Underlined.args = { inputVariant: "underlined" };
139
+ export const Underlined: Story = {
140
+ args: { inputVariant: "underlined" },
141
+ render: renderWithSlots,
142
+ parameters: { docs: { description: { story: "`inputVariant=\"underlined\"` keeps only a bottom border." } } },
143
+ };
@@ -1,8 +1,11 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import StorybookComponent from "../InputNumberField.vue";
3
- import type { FormUiTheme } from "~/types/forms/types.forms.d";
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputNumberField from "../InputNumberField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
4
7
 
5
- interface InputNumberFieldStoryArgs {
8
+ interface StoryArgs {
6
9
  modelValue: number;
7
10
  name: string;
8
11
  label: string;
@@ -14,15 +17,16 @@ interface InputNumberFieldStoryArgs {
14
17
  fieldHasError: boolean;
15
18
  required: boolean;
16
19
  theme: FormUiTheme;
17
- inputVariant: string;
20
+ inputVariant: InputUiVariant;
18
21
  stepDownLabel: string;
19
22
  stepUpLabel: string;
20
23
  useStepButtons: boolean;
24
+ descriptionText: string;
21
25
  }
22
26
 
23
- export default {
27
+ const meta: Meta<StoryArgs> = {
24
28
  title: "Components/Forms/Input Number/InputNumberField",
25
- component: StorybookComponent,
29
+ component: InputNumberField,
26
30
  argTypes: {
27
31
  modelValue: { control: "number", table: { category: "Model" } },
28
32
  name: { control: "text", table: { category: "Basic" } },
@@ -46,7 +50,16 @@ export default {
46
50
  },
47
51
  stepDownLabel: { control: "text", description: "Localisation override for the step-down button", table: { category: "Accessibility" } },
48
52
  stepUpLabel: { control: "text", description: "Localisation override for the step-up button", table: { category: "Accessibility" } },
49
- useStepButtons: { control: "boolean", description: "Render +/- step buttons via left/right slots", table: { category: "Slots" } },
53
+ useStepButtons: {
54
+ control: "boolean",
55
+ description: "Story-only: render the +/- step buttons by filling the left/right slots",
56
+ table: { category: "Slots" },
57
+ },
58
+ descriptionText: {
59
+ control: "text",
60
+ description: "Story-only: fills the descriptionText slot",
61
+ table: { category: "Slots" },
62
+ },
50
63
  },
51
64
  args: {
52
65
  modelValue: 5,
@@ -64,60 +77,113 @@ export default {
64
77
  stepDownLabel: "Step down",
65
78
  stepUpLabel: "Step up",
66
79
  useStepButtons: false,
80
+ descriptionText: "",
81
+ },
82
+ decorators: [
83
+ (story, context) => ({
84
+ components: { story, CanvasSwitcher },
85
+ setup() {
86
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
87
+ return { canvasName };
88
+ },
89
+ template: `
90
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
91
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
92
+ </div>
93
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
94
+ <story />
95
+ </div>
96
+ `,
97
+ }),
98
+ ],
99
+ parameters: {
100
+ layout: "fullscreen",
101
+ docs: {
102
+ description: {
103
+ component:
104
+ "Labelled number field: `InputLabel`, an optional description, `InputNumber` and an error strip. Filling the `left`/`right` slots with an icon adds step buttons: they step by `step`, round away floating-point drift, clamp to `min`/`max` (pulling a typed out-of-range value back in) and start from 0, clamped, when the field is empty. `stepDownLabel`/`stepUpLabel` localise their accessible names.",
105
+ },
106
+ },
67
107
  },
68
- } as Meta<InputNumberFieldStoryArgs>;
108
+ };
109
+
110
+ export default meta;
111
+ type Story = StoryObj<StoryArgs>;
69
112
 
70
- const Template: StoryFn<InputNumberFieldStoryArgs> = (args) => ({
71
- components: { StorybookComponent },
113
+ const render = (args: StoryArgs) => ({
114
+ components: { InputNumberField },
72
115
  setup() {
73
- return { args };
116
+ const componentArgs = computed(() => {
117
+ const { modelValue: _modelValue, useStepButtons: _useStepButtons, descriptionText: _descriptionText, ...rest } = args;
118
+ return rest;
119
+ });
120
+ return { args, componentArgs };
74
121
  },
75
122
  template: `
76
- <div style="margin: 36px; max-width: 320px;">
77
- <StorybookComponent
78
- v-model="args.modelValue"
79
- :name="args.name"
80
- :label="args.label"
81
- :min="args.min"
82
- :max="args.max"
83
- :step="args.step"
84
- :placeholder="args.placeholder"
85
- :error-message="args.errorMessage"
86
- :field-has-error="args.fieldHasError"
87
- :required="args.required"
88
- :theme="args.theme"
89
- :input-variant="args.inputVariant"
90
- :step-down-label="args.stepDownLabel"
91
- :step-up-label="args.stepUpLabel"
92
- >
93
- <template v-if="args.useStepButtons" #left><span>−</span></template>
94
- <template v-if="args.useStepButtons" #right><span>+</span></template>
95
- </StorybookComponent>
96
- </div>
123
+ <InputNumberField v-model="args.modelValue" v-bind="componentArgs">
124
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
125
+ <template v-if="args.useStepButtons" #left><Icon name="mdi:minus" /></template>
126
+ <template v-if="args.useStepButtons" #right><Icon name="mdi:plus" /></template>
127
+ </InputNumberField>
128
+ <p style="margin-block-start: 1.6rem; font-family: monospace;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
97
129
  `,
98
130
  });
99
131
 
100
- export const Default = Template.bind({});
132
+ export const Default: Story = { render };
133
+
134
+ export const WithStepButtons: Story = {
135
+ args: { useStepButtons: true },
136
+ render,
137
+ parameters: { docs: { description: { story: "Step buttons from the `left`/`right` slots. Step-down goes read-only at `min`, step-up at `max`." } } },
138
+ };
101
139
 
102
- export const WithStepButtons = Template.bind({});
103
- WithStepButtons.args = { useStepButtons: true };
140
+ export const DecimalSteps: Story = {
141
+ args: { useStepButtons: true, label: "Weight (kg)", modelValue: 0.2, min: 0, max: 2, step: 0.1 },
142
+ render,
143
+ parameters: {
144
+ docs: { description: { story: "A fractional `step`: stepping up from 0.2 gives 0.3, not 0.30000000000000004, and the mobile keyboard is `decimal`." } },
145
+ },
146
+ };
104
147
 
105
- export const WithError = Template.bind({});
106
- WithError.args = {
107
- fieldHasError: true,
108
- errorMessage: "Please enter a valid quantity",
109
- theme: "error",
148
+ export const WithError: Story = {
149
+ args: { fieldHasError: true, errorMessage: "Please enter a valid quantity", theme: "error" },
150
+ render,
110
151
  };
111
152
 
112
- export const Required = Template.bind({});
113
- Required.args = { required: true };
153
+ export const Required: Story = { args: { required: true }, render };
114
154
 
115
- export const LocalisedLabels = Template.bind({});
116
- LocalisedLabels.args = {
117
- useStepButtons: true,
118
- stepDownLabel: "Diminuer",
119
- stepUpLabel: "Augmenter",
155
+ export const LocalisedLabels: Story = {
156
+ args: { useStepButtons: true, stepDownLabel: "Diminuer", stepUpLabel: "Augmenter" },
157
+ render,
120
158
  };
121
159
 
122
- export const Underlined = Template.bind({});
123
- Underlined.args = { inputVariant: "underlined", useStepButtons: true };
160
+ export const Underlined: Story = { args: { inputVariant: "underlined", useStepButtons: true }, render };
161
+
162
+ export const StressTest: Story = {
163
+ name: "Stress Test (Worst-Case Data)",
164
+ args: {
165
+ useStepButtons: true,
166
+ modelValue: 123456789012,
167
+ min: -999999999999,
168
+ max: 999999999999999,
169
+ step: 0.0001,
170
+ label:
171
+ "🔢 Gewünschteanzahlderteilnehmendenpersonenfürdiegruppenbuchung https://example.com/a/very/long/unbroken/url <b>not bold</b> عدد",
172
+ descriptionText: "يرجى إدخال رقم Geben Sie eine Zahl zwischen minus einer Billion und einer Billiarde ein.",
173
+ placeholder: "Bitte eine sehr lange Platzhalterzahl eingeben",
174
+ errorMessage: "DiesezahlliegtleideraußerhalbdeszulässigenBereichsbitteversuchenSieeserneut",
175
+ fieldHasError: true,
176
+ stepDownLabel: "Verringern 🔽",
177
+ stepUpLabel: "Erhöhen 🔼",
178
+ },
179
+ render,
180
+ parameters: {
181
+ initialCanvas: "mobileCanvas",
182
+ docs: {
183
+ description: {
184
+ story:
185
+ "A 12-digit value, a min of minus a trillion, a 15-digit max and a 0.0001 step, with long unbroken German/URL/emoji/RTL/HTML-like copy. Check at every canvas width: the label, description and error wrap inside the dashed outline, the control's minimum width is capped (12em) so it fits a phone, the step buttons keep their size, and markup renders as text. Clear the field and press step-up (starts from 0), type 9999999999999999 and press step-down (clamps to max), and set `step` to 0 or a negative number.",
186
+ },
187
+ },
188
+ },
189
+ };
@@ -12,6 +12,11 @@ const initialPropsData = {
12
12
  };
13
13
 
14
14
  let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
15
+
16
+ afterEach(() => {
17
+ wrapper?.unmount();
18
+ vi.restoreAllMocks();
19
+ });
15
20
  const wrapperFactory = (propsData = {}, slots = {}) => {
16
21
  const mockPropsData = { ...initialPropsData, ...propsData };
17
22
 
@@ -70,10 +75,10 @@ describe("InputNumber", () => {
70
75
 
71
76
  it("does not render the left/right slot wrappers when the slots are not used", async () => {
72
77
  wrapper = await wrapperFactory();
73
- expect(wrapper.find(".slot.left-slot").exists()).toBe(false);
74
- expect(wrapper.find(".slot.right-slot").exists()).toBe(false);
75
- expect(wrapper.find(".input-number-wrapper").classes()).not.toContain("has-left-slot");
76
- expect(wrapper.find(".input-number-wrapper").classes()).not.toContain("has-right-slot");
78
+ expect(wrapper.find(".input-number-slot-left").exists()).toBe(false);
79
+ expect(wrapper.find(".input-number-slot-right").exists()).toBe(false);
80
+ expect(wrapper.find(".input-number-wrapper").attributes("data-has-left-slot")).toBeUndefined();
81
+ expect(wrapper.find(".input-number-wrapper").attributes("data-has-right-slot")).toBeUndefined();
77
82
  });
78
83
 
79
84
  it("renders the left/right slots when provided", async () => {
@@ -85,10 +90,10 @@ describe("InputNumber", () => {
85
90
  }
86
91
  );
87
92
 
88
- expect(wrapper.find(".slot.left-slot [data-testid='left-slot']").exists()).toBe(true);
89
- expect(wrapper.find(".slot.right-slot [data-testid='right-slot']").exists()).toBe(true);
90
- expect(wrapper.find(".input-number-wrapper").classes()).toContain("has-left-slot");
91
- expect(wrapper.find(".input-number-wrapper").classes()).toContain("has-right-slot");
93
+ expect(wrapper.find(".input-number-slot-left [data-testid='left-slot']").exists()).toBe(true);
94
+ expect(wrapper.find(".input-number-slot-right [data-testid='right-slot']").exists()).toBe(true);
95
+ expect(wrapper.find(".input-number-wrapper").attributes("data-has-left-slot")).toBe("");
96
+ expect(wrapper.find(".input-number-wrapper").attributes("data-has-right-slot")).toBe("");
92
97
  });
93
98
 
94
99
  it("applies the weight class from the weight prop", async () => {
@@ -106,14 +111,45 @@ describe("InputNumber", () => {
106
111
  expect(wrapper.find("input").attributes("required")).toBeDefined();
107
112
  });
108
113
 
109
- it("defaults inputVariant to normal", async () => {
114
+ it("defaults data-input-variant to normal", async () => {
110
115
  wrapper = await wrapperFactory();
111
- expect(wrapper.find(".input-number-wrapper").classes()).toContain("normal");
116
+ expect(wrapper.find(".input-number-wrapper").attributes("data-input-variant")).toBe("normal");
112
117
  });
113
118
 
114
- it("applies inputVariant as a class on the wrapper", async () => {
119
+ it("renders inputVariant as data-input-variant, not a bare class", async () => {
115
120
  wrapper = await wrapperFactory({ inputVariant: "underlined" });
116
- expect(wrapper.find(".input-number-wrapper").classes()).toContain("underlined");
117
- expect(wrapper.find(".input-number-wrapper").classes()).not.toContain("normal");
121
+ const root = wrapper.find(".input-number-wrapper");
122
+ expect(root.attributes("data-input-variant")).toBe("underlined");
123
+ expect(root.classes()).not.toContain("underlined");
124
+ });
125
+
126
+ it("doesn't set a pattern, which number inputs ignore", async () => {
127
+ wrapper = await wrapperFactory();
128
+ expect(wrapper.find("input").attributes("pattern")).toBeUndefined();
129
+ });
130
+
131
+ it("picks inputmode from min and step", async () => {
132
+ wrapper = await wrapperFactory();
133
+ expect(wrapper.find("input").attributes("inputmode")).toBe("numeric");
134
+
135
+ await wrapper.setProps({ step: 0.5 });
136
+ expect(wrapper.find("input").attributes("inputmode")).toBe("decimal");
137
+
138
+ await wrapper.setProps({ min: -10 });
139
+ expect(wrapper.find("input").attributes("inputmode")).toBeUndefined();
140
+ });
141
+
142
+ it("caps the minimum width for very long min and max values", async () => {
143
+ wrapper = await wrapperFactory({ min: -999999999999, max: 999999999999999, step: 0.0001 });
144
+ const vm = wrapper.vm as unknown as { minLength: string };
145
+ expect(vm.minLength).toBe("12em");
146
+ });
147
+
148
+ it("updates passthrough classes when the prop changes", async () => {
149
+ wrapper = await wrapperFactory({ styleClassPassthrough: ["first"] });
150
+ await wrapper.setProps({ styleClassPassthrough: ["second"] });
151
+ const classes = wrapper.find("input").classes();
152
+ expect(classes).toContain("second");
153
+ expect(classes).not.toContain("first");
118
154
  });
119
155
  });
@@ -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
 
@@ -44,10 +49,10 @@ describe("InputNumberField", () => {
44
49
  expect(wrapper.find("input").attributes("placeholder")).toBe("Enter a quantity");
45
50
  });
46
51
 
47
- it("sets data-invalid and the error class when fieldHasError is true", async () => {
52
+ it("sets data-invalid, not a bare error class, when fieldHasError is true", async () => {
48
53
  wrapper = await wrapperFactory({ fieldHasError: true });
49
54
  expect(wrapper.find(".input-number-field").attributes("data-invalid")).toBe("");
50
- expect(wrapper.find(".input-number-field").classes()).toContain("error");
55
+ expect(wrapper.find(".input-number-field").classes()).not.toContain("error");
51
56
  });
52
57
 
53
58
  it("does not render step buttons when the left/right slots are not used", async () => {
@@ -130,14 +135,63 @@ describe("InputNumberField", () => {
130
135
  expect(wrapper.text()).toContain("This field is required");
131
136
  });
132
137
 
133
- it("applies styleClassPassthrough", async () => {
138
+ it("applies styleClassPassthrough to the root only, not the inner input", async () => {
134
139
  wrapper = await wrapperFactory({ styleClassPassthrough: "custom-number" });
135
140
  expect(wrapper.find(".input-number-field").classes()).toContain("custom-number");
141
+ expect(wrapper.find("input").classes()).not.toContain("custom-number");
136
142
  });
137
143
 
138
144
  it("forwards inputVariant to the wrapper, InputLabel, and InputNumber", async () => {
139
145
  wrapper = await wrapperFactory({ inputVariant: "underlined" });
140
- expect(wrapper.find(".input-number-field").classes()).toContain("underlined");
141
- expect(wrapper.find(".input-number-wrapper").classes()).toContain("underlined");
146
+ expect(wrapper.find(".input-number-field").attributes("data-input-variant")).toBe("underlined");
147
+ expect(wrapper.find(".input-number-wrapper").attributes("data-input-variant")).toBe("underlined");
148
+ expect(wrapper.find(".input-error-message").attributes("data-input-variant")).toBe("underlined");
149
+ });
150
+
151
+ describe("step buttons", () => {
152
+ const stepSlots = { left: "<span>-</span>", right: "<span>+</span>" };
153
+ const stepDown = () => wrapper.findAll("button").find((button) => button.text().includes("-"))!;
154
+ const stepUp = () => wrapper.findAll("button").find((button) => button.text().includes("+"))!;
155
+
156
+ it("rounds away floating-point drift on fractional steps", async () => {
157
+ wrapper = await wrapperFactory({ modelValue: 0.2, min: 0, max: 1, step: 0.1 }, stepSlots);
158
+ await stepUp().trigger("click");
159
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([0.3]);
160
+ });
161
+
162
+ it("clamps to max when a step would overshoot it", async () => {
163
+ wrapper = await wrapperFactory({ modelValue: 8, min: 0, max: 10, step: 5 }, stepSlots);
164
+ await stepUp().trigger("click");
165
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([10]);
166
+ });
167
+
168
+ it("clamps to min when a step would undershoot it", async () => {
169
+ wrapper = await wrapperFactory({ modelValue: 2, min: 0, max: 10, step: 5 }, stepSlots);
170
+ await stepDown().trigger("click");
171
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([0]);
172
+ });
173
+
174
+ it("brings a typed out-of-range value back into range", async () => {
175
+ wrapper = await wrapperFactory({ modelValue: 500, min: 0, max: 100 }, stepSlots);
176
+ await stepDown().trigger("click");
177
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([100]);
178
+ });
179
+
180
+ it("starts from 0, clamped into range, when the field is empty", async () => {
181
+ wrapper = await wrapperFactory({ modelValue: "", min: 5, max: 10 }, stepSlots);
182
+ await stepUp().trigger("click");
183
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([5]);
184
+ });
185
+
186
+ it("treats a negative or zero step as its size, or 1", async () => {
187
+ wrapper = await wrapperFactory({ modelValue: 5, min: 0, max: 10, step: -2 }, stepSlots);
188
+ await stepUp().trigger("click");
189
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([7]);
190
+
191
+ wrapper.unmount();
192
+ wrapper = await wrapperFactory({ modelValue: 5, min: 0, max: 10, step: 0 }, stepSlots);
193
+ await stepUp().trigger("click");
194
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([6]);
195
+ });
142
196
  });
143
197
  });
@@ -26,6 +26,17 @@ No private tokens.
26
26
 
27
27
  > **Changed 2026-09-27**: the gap and top margin were hardcoded; they're now tokens with the same
28
28
  > defaults, matching `MultipleCheckboxes`.
29
+ >
30
+ > **Changed 2026-10-16**: the equal-width column minimum is capped at `min(100%, ...)` so one long
31
+ > label can no longer push the grid wider than its container.
32
+
33
+ ### Root fieldset
34
+
35
+ `.multiple-radiobuttons-fieldset` sets `min-inline-size: 0` and `overflow-wrap: anywhere` (added
36
+ 2026-10-16). A native `<fieldset>` otherwise grows to its min-content width and overflows a narrow
37
+ column; the wrap setting inherits into the legend, description, option labels and error, so long
38
+ unbroken words and URLs wrap. Neither is a token; override `overflow-wrap` on your passthrough
39
+ class if you need the old behaviour.
29
40
 
30
41
  ---
31
42
 
@@ -28,7 +28,6 @@
28
28
  <template v-for="item in fieldData.data" :key="item.id">
29
29
  <InputCheckboxRadioButton
30
30
  v-if="isButton"
31
- :id="`${name}-${item.value}`"
32
31
  v-model="modelValue"
33
32
  type="radio"
34
33
  :name
@@ -110,7 +109,14 @@ const props = withDefaults(defineProps<Props>(), {
110
109
 
111
110
  const slots = useSlots();
112
111
 
113
- const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
112
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
113
+
114
+ watch(
115
+ () => props.styleClassPassthrough,
116
+ () => {
117
+ resetElementClasses(props.styleClassPassthrough);
118
+ }
119
+ );
114
120
 
115
121
  const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
116
122
  props.name,
@@ -140,6 +146,11 @@ watch(
140
146
 
141
147
  <style lang="css">
142
148
  @layer components {
149
+ .multiple-radiobuttons-fieldset {
150
+ min-inline-size: 0;
151
+ overflow-wrap: anywhere;
152
+ }
153
+
143
154
  .multiple-radiobuttons-items {
144
155
  display: flex;
145
156
  gap: var(--multiple-radiobuttons-gap, 1.2rem);
@@ -159,7 +170,7 @@ watch(
159
170
  grid-template-columns: repeat(
160
171
  auto-fit,
161
172
  minmax(
162
- calc(var(--input-checked-icon-gap) + (2 * var(--input-checkbox-label-padding-inline)) + v-bind(maxChildWidth)),
173
+ min(100%, calc(var(--input-checked-icon-gap) + (2 * var(--input-checkbox-label-padding-inline)) + v-bind(maxChildWidth))),
163
174
  1fr
164
175
  )
165
176
  );