srcdev-nuxt-components 9.6.7 → 9.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +18 -1
  3. package/.claude/skills/component-inline-action-button.md +1 -1
  4. package/.claude/skills/component-local-style-override.md +1 -1
  5. package/.claude/skills/component-naming.md +13 -13
  6. package/.claude/skills/components/container-glow.md +2 -2
  7. package/.claude/skills/components/form-field.md +2 -2
  8. package/.claude/skills/components/form-wrapper.md +1 -1
  9. package/.claude/skills/components/glowing-border.md +1 -1
  10. package/.claude/skills/components/input-checkbox-radio.md +19 -3
  11. package/.claude/skills/components/input-checkbox.md +14 -3
  12. package/.claude/skills/components/input-description.md +7 -7
  13. package/.claude/skills/components/input-error.md +3 -3
  14. package/.claude/skills/components/input-label.md +7 -8
  15. package/.claude/skills/components/input-number.md +60 -29
  16. package/.claude/skills/components/input-radio.md +15 -1
  17. package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
  18. package/.claude/skills/components/input-select.md +153 -0
  19. package/.claude/skills/components/input-text.md +220 -0
  20. package/.claude/skills/components/input-textarea.md +147 -0
  21. package/.claude/skills/components/select-menu.md +4 -4
  22. package/.claude/skills/components/toggle-switch.md +143 -0
  23. package/.claude/skills/composable-zod-validation.md +1 -1
  24. package/.claude/skills/icon-sets.md +1 -1
  25. package/.claude/skills/icon-sizing.md +1 -1
  26. package/.claude/skills/index.md +10 -10
  27. package/.claude/skills/storybook-add-story.md +1 -1
  28. package/.claude/skills/testing-add-unit-test.md +17 -0
  29. package/.claude/skills/theming-component-token-pattern.md +8 -8
  30. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  31. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  32. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  33. package/.vscode/srcdev-component-input-select.code-snippets +20 -14
  34. package/.vscode/srcdev-component-input-text.code-snippets +46 -40
  35. package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
  36. package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
  37. package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
  38. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
  39. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
  40. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
  41. package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
  42. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
  43. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
  44. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
  45. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  46. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  47. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  48. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  49. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  50. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  51. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  52. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  53. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  54. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  55. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  56. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  57. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  58. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  59. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  60. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  61. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  62. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +7 -7
  63. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +6 -6
  64. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +50 -6
  65. package/app/components/05.forms/input-number/InputNumber.vue +39 -27
  66. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  67. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  68. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  69. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  70. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  71. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  72. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  73. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  74. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  75. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  76. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  77. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  78. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  79. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  80. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  81. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  82. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
  83. package/app/components/05.forms/input-select/InputSelect.vue +198 -0
  84. package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
  85. package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
  86. package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
  87. package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
  88. package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
  89. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
  90. package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
  91. package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
  92. package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
  93. package/app/components/05.forms/input-text/InputTextField.vue +168 -0
  94. package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
  95. package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
  96. package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
  97. package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
  98. package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
  99. package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
  100. package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
  101. package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
  102. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
  103. package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
  104. package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
  105. package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
  106. package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
  107. package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
  108. package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
  109. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
  110. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
  111. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +34 -36
  112. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
  113. package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
  114. package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
  115. package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
  116. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
  117. package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
  118. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
  119. package/package.json +1 -1
  120. package/.claude/skills/components/input-select-core.md +0 -151
  121. package/.claude/skills/components/input-text-core.md +0 -204
  122. package/.claude/skills/components/input-textarea-core.md +0 -154
  123. package/.claude/skills/components/toggle-switch-core.md +0 -169
  124. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  125. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  126. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
  127. package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
  128. package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
  129. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
  130. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
  131. package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
  132. package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
  133. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
  134. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
  135. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
  136. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
  137. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
  138. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
  139. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
  140. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
  141. package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
  142. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
  143. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
  144. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
  145. package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
  146. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
  147. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
  148. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
  149. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
  150. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
  151. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
  152. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
@@ -1,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 () => {
@@ -1,8 +1,8 @@
1
1
  # InputDescription — Consumer Styling Guide
2
2
 
3
3
  `InputDescription` is the help text rendered between the label and the control by every `*Field`
4
- wrapper (`InputTextWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
5
- `InputTextareaWithLabel`, `InputRangeDefault`, `InputTextAsNumberWithLabel`, `SingleCheckbox`,
4
+ wrapper (`InputTextField`, `InputNumberField`, `InputSelectField`,
5
+ `InputTextareaField`, `InputRangeField`, `InputTextAsNumberField`, `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).
@@ -84,9 +84,9 @@ description. Keep these blocks **unlayered** (no `@layer ...` wrapper) so they b
84
84
  ```vue
85
85
  <template>
86
86
  <div class="contact-page-content">
87
- <InputTextWithLabel v-model="email" name="email" label="Email">
87
+ <InputTextField v-model="email" name="email" label="Email">
88
88
  <template #descriptionText>We'll only use this for your booking confirmation</template>
89
- </InputTextWithLabel>
89
+ </InputTextField>
90
90
  </div>
91
91
  </template>
92
92
 
@@ -108,9 +108,9 @@ The wrappers don't forward `style-class-passthrough` to the description, but a p
108
108
  the wrapper itself lands on its root element, which is an ancestor of the description:
109
109
 
110
110
  ```vue
111
- <InputTextWithLabel class="email-field" v-model="email" name="email" label="Email">
111
+ <InputTextField class="email-field" v-model="email" name="email" label="Email">
112
112
  <template #descriptionText>We'll only use this for your booking confirmation</template>
113
- </InputTextWithLabel>
113
+ </InputTextField>
114
114
 
115
115
  <style>
116
116
  .email-field {
@@ -1,9 +1,9 @@
1
1
  # InputLabel — Consumer Styling Guide
2
2
 
3
- `InputLabel` is the `<label>` rendered by every labelled field wrapper (`InputTextWithLabel`,
4
- `InputTextAsNumberWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
5
- `InputTextareaWithLabel`, `InputRangeDefault`, `ToggleSwitchWithLabel`,
6
- `ToggleSwitchWithLabelInline`).
3
+ `InputLabel` is the `<label>` rendered by every labelled field wrapper (`InputTextField`,
4
+ `InputTextAsNumberField`, `InputNumberField`, `InputSelectField`,
5
+ `InputTextareaField`, `InputRangeField`,
6
+ `ToggleSwitchField`).
7
7
 
8
8
  ## Public token API
9
9
 
@@ -50,8 +50,8 @@ 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`) |
54
- | `data-invalid` | The field has an error (not passed by `ToggleSwitchWithLabelInline`) |
53
+ | `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (`InputRangeField` and the toggle switches always pass `normal`) |
54
+ | `data-invalid` | The field has an error (passed by every Field) |
55
55
 
56
56
  | Attribute on `.input-label-indicator` | When |
57
57
  |---|---|
@@ -7,11 +7,11 @@ to override just this component; leave it unset and it inherits the shared `--th
7
7
  `--theme-*` token every other themed component also falls back to. See
8
8
  `theming-component-token-pattern.md` for why this two-tier shape exists.
9
9
 
10
- This shape mirrors `InputTextCore`'s token set (see its own CONSUMER-STYLING.md) — `InputNumber`
11
- is a native `<input type="number">` sibling to the text-based `InputTextAsNumberWithLabel` variant,
10
+ This shape mirrors `InputText`'s token set (see its own CONSUMER-STYLING.md) — `InputNumber`
11
+ is a native `<input type="number">` sibling to the text-based `InputTextAsNumberField` variant,
12
12
  and both are meant to look and behave the same way. Border colour also doubles as the divider
13
13
  colour between the input and an embedded left/right `InputButton` — there's no separate divider
14
- token, same as `InputTextCore`.
14
+ token, same as `InputText`.
15
15
 
16
16
  | Token | Falls back to | Controls |
17
17
  |---|---|---|
@@ -27,14 +27,47 @@ Geometry (`--input-padding-block`, `--input-padding-inline`, `--input-min-height
27
27
  `--form-input-border-radius`, `--input-font-size`, etc.) is shared across every `05.forms`
28
28
  component and untouched by this — see `theming-form-geometry-tokens.md`.
29
29
 
30
+ Private (not public API): `--_surface`, `--_border` and `--_border-focus` (each resolved once and
31
+ read in several places) and `--_min-width` (the input's minimum width, worked out from `min`, `max`
32
+ and `step` and capped at `12em`).
33
+
34
+ ---
35
+
36
+ ## State hooks
37
+
38
+ | Attribute | On | When |
39
+ |---|---|---|
40
+ | `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-number-wrapper`, `.input-number-field` | Always, mirrors `input-variant` |
41
+ | `data-theme` | `.input-number-wrapper`, `.input-number-field` | Always, mirrors `theme` |
42
+ | `data-invalid` | `.input-number-wrapper`, `.input-number-field` | `field-has-error` |
43
+ | `data-has-left-slot` / `data-has-right-slot` | `.input-number-wrapper` | The `left` / `right` slot is filled |
44
+
45
+ Inner elements: `.input-number-slot` (both slot holders), `.input-number-slot-left`,
46
+ `.input-number-slot-right`, `.input-number-container`, `.input-number` (the native input),
47
+ `.input-number-label` (the Field's `InputLabel`).
48
+
49
+ > **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
50
+ > (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
51
+ > above instead:
52
+ >
53
+ > - `.input-number-wrapper.normal` / `.underlined` → `[data-input-variant="…"]`
54
+ > - `.input-number-wrapper.has-left-slot` / `.has-right-slot` → `[data-has-left-slot]` /
55
+ > `[data-has-right-slot]`
56
+ > - `.slot.left-slot` / `.slot.right-slot` → `.input-number-slot-left` / `.input-number-slot-right`
57
+ > - `.input-number-field.error` → `[data-invalid]`; its `.normal`/`.underlined` and `theme-*`
58
+ > classes → `[data-input-variant]` / `[data-theme]`
59
+ >
60
+ > Also: long label, description and error copy now wraps (`overflow-wrap: anywhere` on
61
+ > `.input-number-field`), and the control's minimum width is capped at `12em` so a long `min`/`max`
62
+ > can't push it past a phone screen.
63
+
30
64
  ---
31
65
 
32
66
  ## Input variant
33
67
 
34
68
  `:input-variant="'normal' | 'outlined' | 'underlined'"` (default `"normal"`) — same prop as
35
- `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
69
+ `InputText`. Only `normal` (bordered box, with hover/focus outline) and `underlined`
70
+ (bottom-border only) have CSS today; `outlined` has no styling in `InputText` either, so passing it renders with no border/outline at all — a
38
71
  pre-existing gap in the type, not something specific to `InputNumber`. Declared for prop-shape
39
72
  consistency with the rest of `05.forms`.
40
73
 
@@ -68,3 +101,14 @@ Set the tokens above on an element you own (a page or section class, or a class
68
101
  <InputNumber id="quantity" name="quantity" :min="1" :max="10" style="--input-number-border: var(--gold-04);" />
69
102
  ```
70
103
 
104
+
105
+ ---
106
+
107
+ ## Class passthrough
108
+
109
+ `InputNumber`'s `:style-class-passthrough` lands on the native `<input class="input-number">`.
110
+ `InputNumberField`'s lands on its root `.input-number-field` only.
111
+
112
+ > **Changed 2026-10-16**: `InputNumberField` also forwarded its passthrough classes to the inner
113
+ > input, so a class meant for the field matched two elements. If you styled the input through the
114
+ > 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">
@@ -81,8 +100,8 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
81
100
 
82
101
  /* Public --input-number-* tokens, inline-fallback to the shared --theme-* tokens (see
83
102
  theming-component-token-pattern.md) — overriding one here doesn't touch every other themed
84
- input/control that also reads --theme-input-surface/--theme-border. Mirrors InputTextCore's
85
- token shape so a native number input and the text-based InputTextAsNumberWithLabel variant
103
+ input/control that also reads --theme-input-surface/--theme-border. Mirrors InputText's
104
+ token shape so a native number input and the text-based InputTextAsNumberField variant
86
105
  look and behave the same way (see input-number.md's "Fixed 2026-09-22" note). */
87
106
  --_surface: var(--input-number-surface, var(--theme-input-surface));
88
107
  --_border: var(--input-number-border, var(--theme-border));
@@ -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>
@@ -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
+ };