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,9 @@
1
- # InputTextCore — Consumer Styling Guide
1
+ # InputText — Consumer Styling Guide
2
+
3
+ > **Renamed 2026-10-16**: `InputTextCore` → `InputText`, `InputTextWithLabel` →
4
+ > `InputTextField`, `InputPasswordWithLabel` → `InputPasswordField`,
5
+ > `InputTextAsNumberWithLabel` → `InputTextAsNumberField`. Class changes are listed under "State
6
+ > hooks" below.
2
7
 
3
8
  ## Public token API
4
9
 
@@ -7,25 +12,69 @@ override just this component; leave it unset and it inherits the shared `--theme
7
12
  `--theme-*` token every other themed component also falls back to. See
8
13
  `theming-component-token-pattern.md` for why this two-tier shape exists.
9
14
 
10
- (Before 2026-08-25 this component used a private `--_input-text-*` naming scheme presented as a
11
- "local override path" — that's gone. A private, underscore-prefixed custom property was never a
12
- real override point; renaming it public with a real fallback is the actual fix, not a second
13
- documented path to reach for.)
14
-
15
- | Token | Falls back to | Controls |
15
+ | Token | Default | Controls |
16
16
  |---|---|---|
17
- | `--input-text-surface` | `var(--theme-input-surface)` | Wrapper background |
18
- | `--input-text-surface-hover` | `var(--theme-input-surface-hover)` | Background of an embedded `InputButton` slot on hover |
19
- | `--input-text-border` | `var(--theme-border)` | Border colour (`.normal`/`.underlined`, resting) |
20
- | `--input-text-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (mouse) |
21
- | `--input-text-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive) |
22
- | `--input-text-color` | `var(--theme-input-text-color-normal)` | Input text colour |
17
+ | `--input-text-surface` | `var(--theme-input-surface)` | Wrapper background, and slot `InputButton` background |
18
+ | `--input-text-surface-hover` | `var(--theme-input-surface-hover)` | Slot `InputButton` background on hover |
19
+ | `--input-text-border` | `var(--theme-border)` | Border colour (`normal`/`underlined`, resting), and the stepper's button dividers |
20
+ | `--input-text-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (`normal` only) |
21
+ | `--input-text-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (`normal` only), and a slot button's own focus outline |
22
+ | `--input-text-color` | `var(--theme-input-text-color-normal)` | Input text and slot button colour, and autofilled text (global rule) |
23
23
  | `--input-text-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
24
24
 
25
- Geometry (`--input-padding-*`, `--input-min-height`, `--input-font-size`,
25
+ Geometry (`--input-padding-*`, `--input-min-height`, `--input-font-size`, `--input-icon-slot-gap`,
26
26
  `--form-element-*`, etc.) is shared across every `05.forms` component and untouched by this —
27
27
  see `theming-form-geometry-tokens.md`.
28
28
 
29
+ Private (not public API): `--_surface`, `--_border` and `--_border-focus`, each resolved once and
30
+ read in several places.
31
+
32
+ ---
33
+
34
+ ## State hooks
35
+
36
+ | Attribute | On | When |
37
+ |---|---|---|
38
+ | `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-text-wrapper`, each Field root | Always, mirrors `input-variant` |
39
+ | `data-theme` | `.input-text-wrapper`, each Field root | Always, mirrors `theme` |
40
+ | `data-invalid` | `.input-text-wrapper`, each Field root | `field-has-error` |
41
+ | `data-dirty` | `.input-text-wrapper`, each Field root | The Field has seen a non-empty value |
42
+ | `data-active` | `.input-text-wrapper`, each Field root | The input has focus |
43
+ | `data-readonly` | `.input-text-wrapper` | `readonly` |
44
+ | `data-inputmode` | `.input-text-wrapper` | Always, mirrors `inputmode` |
45
+ | `data-has-left-slot` / `data-has-right-slot` | `.input-text-wrapper` | The `left` / `right` slot is filled |
46
+
47
+ Field roots: `.input-text-field` (`InputTextField`, and `InputPasswordField`, which adds
48
+ `.input-password-field`), `.input-text-as-number-field` (`InputTextAsNumberField`).
49
+
50
+ Inner elements: `.input-text` (the native input), `.input-text-slot` (both slot holders),
51
+ `.input-text-slot-left`, `.input-text-slot-right`, `.input-text-label` (the Fields' `InputLabel`),
52
+ `.input-text-description` (the Fields' `InputDescription`), `.input-password-field-icon` (the
53
+ password toggle's icon).
54
+
55
+ `outlined` has no CSS of its own: the control renders with no border or outline. In
56
+ `InputTextField` it moves the description after the input and error.
57
+
58
+ > **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
59
+ > (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
60
+ > above instead:
61
+ >
62
+ > - `.input-text-wrapper.normal` / `.underlined` / `.outlined` → `[data-input-variant="…"]`
63
+ > - `.input-text-wrapper.dirty` / `.active` / `.error` → `[data-dirty]` / `[data-active]` /
64
+ > `[data-invalid]`; `.has-left-slot` / `.has-right-slot` → `[data-has-left-slot]` /
65
+ > `[data-has-right-slot]`
66
+ > - `.slot.left-slot` / `.slot.right-slot` → `.input-text-slot-left` / `.input-text-slot-right`
67
+ > - `.input-text-core` → `.input-text` (it also no longer carries `dirty`/`active` classes)
68
+ > - `.input-text-with-label` → `.input-text-field`, now the Field's root (it used to sit inside
69
+ > an unclassed `<div>`); its variant/`dirty`/`active` classes → the attributes above
70
+ > - `InputTextAsNumberField`'s root was `.input-text-with-label.input-text-as-number`; it's now
71
+ > `.input-text-as-number-field`, and the input no longer gets `.input-text-as-number`
72
+ > - the password toggle icon's `.icon` → `.input-password-field-icon`
73
+ >
74
+ > Also: label, description and error copy wraps (`overflow-wrap: anywhere` on each Field root),
75
+ > and the input can shrink below its intrinsic width (`min-inline-size: 0`), so a field no longer
76
+ > overflows a narrow column.
77
+
29
78
  ---
30
79
 
31
80
  ## Global theming
@@ -51,9 +100,30 @@ Set the tokens above on an element you own (a page or section class, or a class
51
100
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
52
101
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
53
102
 
103
+ **Caveat**: `InputText`'s own passthrough lands on the `<input>`, which has no descendants, so
104
+ tokens set through it only reach the input itself (text and placeholder colour). Set the others on
105
+ a Field root or an ancestor.
106
+
54
107
  ### One instance
55
108
 
56
109
  ```vue
57
- <InputTextCore id="email" name="email" style="--input-text-border-focus: var(--gold-04);" />
110
+ <InputTextField
111
+ v-model="email"
112
+ name="email"
113
+ type="email"
114
+ label="Email"
115
+ error-message=""
116
+ style="--input-text-border-focus: var(--gold-04);"
117
+ />
58
118
  ```
59
119
 
120
+ ---
121
+
122
+ ## Class passthrough
123
+
124
+ `InputText`'s `:style-class-passthrough` lands on the native `<input class="input-text">`. Each
125
+ Field's lands on its own root only.
126
+
127
+ > **Changed 2026-10-16**: `InputTextField` and `InputTextAsNumberField` applied their passthrough
128
+ > classes to both their root and the inner input. If you styled the input that way, select
129
+ > `.your-class .input-text` instead.
@@ -1,6 +1,7 @@
1
1
  <template>
2
- <InputTextWithLabel
2
+ <InputTextField
3
3
  v-model="modelValue"
4
+ class="input-password-field"
4
5
  :type="inputType"
5
6
  :maxlength
6
7
  :name
@@ -9,14 +10,15 @@
9
10
  :error-message
10
11
  :field-has-error
11
12
  :required
13
+ :autocomplete
12
14
  :style-class-passthrough
13
15
  :theme
14
16
  :input-variant
15
17
  >
16
- <template #descriptionHtml>
18
+ <template v-if="slots.descriptionHtml" #descriptionHtml>
17
19
  <slot name="descriptionHtml"></slot>
18
20
  </template>
19
- <template #descriptionText>
21
+ <template v-if="slots.descriptionText" #descriptionText>
20
22
  <slot name="descriptionText"></slot>
21
23
  </template>
22
24
 
@@ -30,18 +32,19 @@
30
32
  @click.stop.prevent="toggleDisplayPassword"
31
33
  >
32
34
  <template #iconOnly>
33
- <Icon v-if="displayPassword" name="radix-icons:eye-none" class="icon" />
34
- <Icon v-else name="radix-icons:eye-open" class="icon" />
35
+ <Icon v-if="displayPassword" name="radix-icons:eye-none" class="input-password-field-icon" />
36
+ <Icon v-else name="radix-icons:eye-open" class="input-password-field-icon" />
35
37
  </template>
36
38
  </InputButton>
37
39
  </template>
38
- </InputTextWithLabel>
40
+ </InputTextField>
39
41
  </template>
40
42
 
41
43
  <script setup lang="ts">
42
44
  import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
43
45
 
44
46
  interface Props {
47
+ /** Ignored: the toggle sets the type. Declared so a passed `type` can't override it. */
45
48
  type?: "text" | "password";
46
49
  maxlength?: number;
47
50
  name: string;
@@ -50,12 +53,11 @@ interface Props {
50
53
  errorMessage: object | string;
51
54
  fieldHasError?: boolean;
52
55
  required?: boolean;
56
+ autocomplete?: string;
53
57
  styleClassPassthrough?: string | string[];
54
58
  theme?: FormUiTheme;
55
59
  inputVariant?: InputUiVariant;
56
- /** Accessible name for the toggle button while the password is hidden. */
57
60
  showPasswordText?: string;
58
- /** Accessible name for the toggle button while the password is visible. */
59
61
  hidePasswordText?: string;
60
62
  }
61
63
 
@@ -65,6 +67,7 @@ const props = withDefaults(defineProps<Props>(), {
65
67
  placeholder: "",
66
68
  fieldHasError: false,
67
69
  required: false,
70
+ autocomplete: "current-password",
68
71
  styleClassPassthrough: () => [],
69
72
  theme: "default",
70
73
  inputVariant: "normal",
@@ -72,21 +75,15 @@ const props = withDefaults(defineProps<Props>(), {
72
75
  hidePasswordText: "Hide password",
73
76
  });
74
77
 
75
- const modelValue = defineModel<string>({ required: true });
76
-
77
- const { updateElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
78
+ const slots = useSlots();
78
79
 
79
- const inputType = computed<"text" | "password">(() => {
80
- return displayPassword.value ? "text" : "password";
81
- });
80
+ const modelValue = defineModel<string>({ required: true });
82
81
 
83
82
  const displayPassword = ref(false);
84
- const buttonText = computed(() => {
85
- return displayPassword.value ? props.hidePasswordText : props.showPasswordText;
86
- });
83
+ const inputType = computed<"text" | "password">(() => (displayPassword.value ? "text" : "password"));
84
+ const buttonText = computed(() => (displayPassword.value ? props.hidePasswordText : props.showPasswordText));
85
+
87
86
  const toggleDisplayPassword = () => {
88
87
  displayPassword.value = !displayPassword.value;
89
88
  };
90
-
91
- updateElementClasses(["has-right-button"]);
92
89
  </script>
@@ -3,42 +3,42 @@
3
3
  class="input-text-wrapper"
4
4
  data-testid="input-text-wrapper"
5
5
  :data-theme="theme"
6
+ :data-input-variant="inputVariant"
6
7
  :data-invalid="fieldHasError ? '' : null"
8
+ :data-dirty="isDirty ? '' : null"
9
+ :data-active="isActive ? '' : null"
10
+ :data-readonly="readonly ? '' : null"
7
11
  :data-inputmode="inputmode"
8
- :class="[
9
- inputVariant,
10
- { dirty: isDirty },
11
- { active: isActive },
12
- { error: fieldHasError },
13
- { 'has-left-slot': slots.left },
14
- { 'has-right-slot': slots.right },
15
- ]"
12
+ :data-has-left-slot="slots.left ? '' : null"
13
+ :data-has-right-slot="slots.right ? '' : null"
16
14
  >
17
- <div v-if="slots.left" class="slot left-slot">
15
+ <div v-if="slots.left" class="input-text-slot input-text-slot-left">
18
16
  <slot name="left"></slot>
19
17
  </div>
20
18
 
21
19
  <input
22
20
  :id
23
- ref="inputField"
24
21
  v-model="modelValue"
25
22
  :type
26
23
  :placeholder
27
24
  :name
28
25
  :required
26
+ :readonly
27
+ :autocomplete
29
28
  :maxlength
30
29
  :min
31
30
  :max
32
- :class="['input-text-core', elementClasses, { dirty: isDirty }, { active: isActive }]"
31
+ :class="['input-text', elementClasses]"
33
32
  :aria-invalid="fieldHasError ? 'true' : undefined"
34
- :aria-describedby
33
+ :aria-describedby="ariaDescribedby || undefined"
35
34
  :pattern="inputPattern"
36
35
  :inputmode
37
- @focusin="updateFocus(true)"
38
- @focusout="updateFocus(false)"
36
+ @beforeinput="filterInput"
37
+ @focusin="isActive = true"
38
+ @focusout="isActive = false"
39
39
  />
40
40
 
41
- <div v-if="slots.right" class="slot right-slot">
41
+ <div v-if="slots.right" class="input-text-slot input-text-slot-right">
42
42
  <slot name="right"></slot>
43
43
  </div>
44
44
  </div>
@@ -51,13 +51,13 @@ interface Props {
51
51
  type?: InputTypesText;
52
52
  inputmode?: InputMode;
53
53
  maxlength?: number;
54
- /** Passed straight through to the native input — meaningful for type="date"/"number" etc. */
55
54
  min?: string | number;
56
- /** Passed straight through to the native input — meaningful for type="date"/"number" etc. */
57
55
  max?: string | number;
58
56
  id: string;
59
57
  name: string;
60
58
  required?: boolean;
59
+ readonly?: boolean;
60
+ autocomplete?: string;
61
61
  placeholder?: string;
62
62
  fieldHasError?: boolean;
63
63
  styleClassPassthrough?: string | string[];
@@ -73,6 +73,8 @@ const props = withDefaults(defineProps<Props>(), {
73
73
  min: undefined,
74
74
  max: undefined,
75
75
  required: false,
76
+ readonly: false,
77
+ autocomplete: undefined,
76
78
  placeholder: "",
77
79
  fieldHasError: false,
78
80
  styleClassPassthrough: () => [],
@@ -83,64 +85,32 @@ const props = withDefaults(defineProps<Props>(), {
83
85
 
84
86
  const slots = useSlots();
85
87
 
86
- const FormUiTheme = computed(() => {
87
- return props.fieldHasError ? "error" : props.theme;
88
- });
89
-
90
88
  const modelValue = defineModel<string>({ required: true });
91
89
  const isDirty = defineModel<boolean>("isDirty");
92
90
  const isActive = defineModel<boolean>("isActive");
93
91
 
94
- const inputPattern = computed(() => {
95
- return props.inputmode === "numeric" ? "[0-9]+" : undefined;
96
- });
97
-
98
- const updateFocus = (isFocused: boolean) => {
99
- isActive.value = isFocused;
100
- };
92
+ const inputPattern = computed(() => (props.inputmode === "numeric" ? "[0-9]+" : undefined));
101
93
 
102
- const inputField = ref<HTMLInputElement | null>(null);
103
-
104
- const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
105
-
106
- // TODO: Move this to a utility function to allow removeEventListener on unmounted
107
- // Leaving like this could lead to memory leaks
108
- const validateInput = () => {
109
- if (inputField.value !== null) {
110
- inputField.value.addEventListener("beforeinput", (event: InputEvent) => {
111
- const beforeValue = modelValue.value;
112
- if (event.data === null) {
113
- // Handle deletion case
114
- return;
115
- } else if (event.target) {
116
- event.target.addEventListener(
117
- "input",
118
- () => {
119
- if (inputField.value !== null && inputField.value.validity.patternMismatch) {
120
- inputField.value.value = beforeValue as string;
121
- }
122
- },
123
- { once: true }
124
- );
125
- }
126
- });
94
+ const filterInput = (event: Event) => {
95
+ const data = (event as InputEvent).data;
96
+ if (props.inputmode === "numeric" && data !== null && !/^[0-9]*$/.test(data)) {
97
+ event.preventDefault();
127
98
  }
128
99
  };
129
100
 
130
- onMounted(() => {
131
- if (props.inputmode === "numeric") validateInput();
132
- });
101
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
102
+
103
+ watch(
104
+ () => props.styleClassPassthrough,
105
+ () => {
106
+ resetElementClasses(props.styleClassPassthrough);
107
+ }
108
+ );
133
109
  </script>
134
110
 
135
111
  <style lang="css">
136
112
  @layer components {
137
113
  .input-text-wrapper {
138
- /* Public --input-text-* tokens, inline-fallback to the shared --theme-* tokens (see
139
- theming-component-token-pattern.md) — overriding one here doesn't touch every other themed
140
- input/control that also reads --theme-input-surface/--theme-border. --_surface etc. are
141
- private locals purely so the rest of this rule can reuse the resolved value without
142
- repeating the fallback chain at every property — they are not themselves an override point,
143
- see CONSUMER-STYLING.md. */
144
114
  --_surface: var(--input-text-surface, var(--theme-input-surface));
145
115
  --_border: var(--input-text-border, var(--theme-border));
146
116
  --_border-focus: var(--input-text-border-focus, var(--theme-border-focus));
@@ -148,6 +118,8 @@ onMounted(() => {
148
118
  display: flex;
149
119
  align-items: center;
150
120
  gap: var(--input-icon-slot-gap);
121
+ min-inline-size: 0;
122
+ max-inline-size: 100%;
151
123
  background-color: var(--_surface);
152
124
  overflow: hidden;
153
125
  transition: all var(--theme-form-transition-duration) ease-in-out;
@@ -155,7 +127,7 @@ onMounted(() => {
155
127
  z-index: 2;
156
128
  position: relative;
157
129
 
158
- &.normal {
130
+ &[data-input-variant="normal"] {
159
131
  border: var(--form-element-border-width) solid var(--_border);
160
132
  border-radius: var(--form-input-border-radius);
161
133
  outline: var(--form-element-outline-width) solid transparent;
@@ -173,30 +145,24 @@ onMounted(() => {
173
145
  }
174
146
  }
175
147
 
176
- &.underlined {
148
+ &[data-input-variant="underlined"] {
177
149
  border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
178
150
  padding-inline: var(--input-padding-inline);
179
151
  }
180
152
 
181
- &.has-right-slot:has(.input-button) {
153
+ &[data-has-right-slot]:has(.input-text-slot-right .input-button) {
182
154
  padding-inline-end: 0;
183
155
  }
184
156
 
185
- .slot {
157
+ .input-text-slot {
186
158
  display: flex;
187
159
  align-items: center;
188
160
  justify-content: center;
161
+ flex-shrink: 0;
189
162
  box-sizing: content-box;
190
163
 
164
+ /* variant="inline" InputButton ships unstyled; the slot supplies its border, colour and outline. */
191
165
  .input-button {
192
- /* variant="inline" ships with zero border/colour styling by design (see
193
- component-inline-action-button.md) — the parent supplies all of it. This rule was
194
- always missing the border/colour half of that contract, only ever setting
195
- background-color; harmless before InputButton's flat-button redesign, which
196
- removed the unscoped base rule that used to accidentally give every variant a real
197
- border regardless. Without its own border/color/outline reset, this button now falls
198
- through to the browser's UA default <button> chrome (a stark outset border and
199
- black/white text) instead of sitting flush with the input. */
200
166
  background-color: var(--_surface);
201
167
  color: var(--input-text-color, var(--theme-input-text-color-normal));
202
168
  border: none;
@@ -222,20 +188,18 @@ onMounted(() => {
222
188
  }
223
189
  }
224
190
 
225
- .input-text-core {
191
+ .input-text {
226
192
  all: unset;
227
193
  touch-action: manipulation;
228
194
  flex-grow: 1;
195
+ min-inline-size: 0;
229
196
 
230
197
  color: var(--input-text-color, var(--theme-input-text-color-normal));
231
198
  font-family: var(--font-family);
232
199
  font-size: var(--input-font-size);
233
200
 
234
- /* line-height: var(--input-element-line-height); */
235
-
236
201
  padding-block: var(--input-padding-block);
237
202
  padding-inline: var(--input-padding-inline);
238
- /* height: stretch; */
239
203
  min-height: var(--input-min-height);
240
204
 
241
205
  &::placeholder {
@@ -1,9 +1,12 @@
1
1
  <template>
2
2
  <div
3
- class="input-text-with-label"
3
+ class="input-text-as-number-field"
4
+ :class="elementClasses"
4
5
  :data-theme="theme"
6
+ :data-input-variant="inputVariant"
5
7
  :data-invalid="fieldHasError ? '' : null"
6
- :class="[inputVariant, elementClasses, { dirty: isDirty }, { active: isActive }]"
8
+ :data-dirty="isDirty ? '' : null"
9
+ :data-active="isActive ? '' : null"
7
10
  >
8
11
  <InputLabel
9
12
  :id
@@ -29,7 +32,7 @@
29
32
  </template>
30
33
  </InputDescription>
31
34
 
32
- <InputTextCore
35
+ <InputText
33
36
  :id
34
37
  v-model="internalStringValue"
35
38
  v-model:is-dirty="isDirty"
@@ -38,14 +41,11 @@
38
41
  :maxlength
39
42
  :name
40
43
  :placeholder
41
- :label
42
- :error-message
43
44
  :field-has-error
44
45
  :required
45
- :style-class-passthrough="elementClasses"
46
46
  :theme
47
- inputmode="numeric"
48
- :aria-describedby="ariaDescribedby()"
47
+ :inputmode
48
+ :aria-describedby="ariaDescribedby() ?? undefined"
49
49
  :input-variant
50
50
  >
51
51
  <template v-if="slots.left" #left>
@@ -54,9 +54,9 @@
54
54
  :readonly="(modelValue ?? 0) <= min"
55
55
  :is-pending="false"
56
56
  :button-text="stepDownText"
57
- theme="default"
57
+ :theme
58
58
  variant="inline"
59
- @click.stop.prevent="updateValue(-step, (modelValue ?? 0) > min)"
59
+ @click.stop.prevent="stepBy(-1)"
60
60
  >
61
61
  <template #iconOnly>
62
62
  <slot name="left"></slot>
@@ -69,17 +69,17 @@
69
69
  :readonly="(modelValue ?? 0) >= max"
70
70
  :is-pending="false"
71
71
  :button-text="stepUpText"
72
- theme="default"
72
+ :theme
73
73
  variant="inline"
74
- @click.stop.prevent="updateValue(step, (modelValue ?? 0) < max)"
74
+ @click.stop.prevent="stepBy(1)"
75
75
  >
76
76
  <template #iconOnly>
77
77
  <slot name="right"></slot>
78
78
  </template>
79
79
  </InputButton>
80
80
  </template>
81
- </InputTextCore>
82
- <InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" />
81
+ </InputText>
82
+ <InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" :input-variant />
83
83
  </div>
84
84
  </template>
85
85
 
@@ -100,9 +100,7 @@ interface Props {
100
100
  max: number;
101
101
  step?: number;
102
102
  inputVariant?: InputUiVariant;
103
- /** Accessible name for the decrement button. */
104
103
  stepDownText?: string;
105
- /** Accessible name for the increment button. */
106
104
  stepUpText?: string;
107
105
  }
108
106
 
@@ -131,71 +129,77 @@ const modelValue = defineModel<number | undefined>({ default: undefined });
131
129
  const isActive = ref<boolean>(false);
132
130
  const isDirty = ref<boolean>(false);
133
131
 
134
- // Convert between string (for input) and number (for modelValue)
135
132
  const internalStringValue = computed({
136
133
  get: () => modelValue.value?.toString() ?? "",
137
134
  set: (value: string) => {
138
- const numericValue = value === "" ? undefined : Number(value);
139
- // Only update if it's a valid number or empty string
140
- if (value === "" || !isNaN(numericValue as number)) {
141
- modelValue.value = numericValue;
135
+ if (value === "") {
136
+ modelValue.value = undefined;
137
+ return;
142
138
  }
139
+ const numericValue = Number(value);
140
+ if (Number.isFinite(numericValue)) modelValue.value = numericValue;
143
141
  },
144
142
  });
145
143
 
146
- const { elementClasses, updateElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
147
- const minLength = computed(() => `${props.max.toString().length + 1}em`);
144
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
148
145
 
149
- const updateValue = (step: number, withinRangeLimit: boolean) => {
150
- if (withinRangeLimit) {
151
- const currentValue = modelValue.value ?? 0;
152
- modelValue.value = currentValue + step;
146
+ watch(
147
+ () => props.styleClassPassthrough,
148
+ () => {
149
+ resetElementClasses(props.styleClassPassthrough);
153
150
  }
154
- };
151
+ );
152
+
153
+ const decimalPlaces = (value: number) => (String(value).split(".")[1] ?? "").length;
154
+ const clamp = (value: number) => Math.min(Math.max(value, props.min), props.max);
155
+
156
+ const inputmode = computed(() => {
157
+ if (props.min < 0) return "text";
158
+ return Number.isInteger(props.step) ? "numeric" : "decimal";
159
+ });
160
+
161
+ const minLength = computed(() => {
162
+ const longest = Math.max(String(props.min).length, String(props.max).length) + decimalPlaces(props.step);
163
+ return `${Math.min(longest + 1, 12)}em`;
164
+ });
155
165
 
156
- updateElementClasses(["input-text-as-number", "has-left-button", "has-right-button"]);
166
+ const stepBy = (direction: 1 | -1) => {
167
+ const current = modelValue.value;
168
+ if (typeof current !== "number" || !Number.isFinite(current)) {
169
+ modelValue.value = clamp(0);
170
+ return;
171
+ }
172
+ const stepSize = Math.abs(props.step) || 1;
173
+ const precision = Math.max(decimalPlaces(stepSize), decimalPlaces(current), decimalPlaces(props.min));
174
+ const next = clamp(Number((current + direction * stepSize).toFixed(precision)));
175
+ if (next !== current) modelValue.value = next;
176
+ };
157
177
  </script>
158
178
 
159
179
  <style lang="css">
160
180
  @layer components {
161
- .input-text-as-number {
162
- .input-text-wrapper {
181
+ .input-text-as-number-field {
182
+ min-inline-size: 0;
183
+ overflow-wrap: anywhere;
184
+
185
+ .input-text-wrapper[data-input-variant] {
163
186
  width: fit-content;
187
+ max-inline-size: 100%;
164
188
  padding-inline: 0;
165
189
 
166
- &:has(.input-text-as-number) {
167
- .slot {
168
- margin-inline: 0;
169
-
170
- .input-button {
171
- background-color: var(--theme-input-surface);
172
-
173
- &:hover {
174
- background-color: var(--theme-input-surface-hover);
175
- }
176
-
177
- &.icon-only {
178
- .btn-icon {
179
- margin: 0;
180
- }
181
- }
182
- }
183
-
184
- &.left-slot {
185
- .input-button {
186
- border-right: var(--form-element-border-width) solid var(--theme-border);
187
- }
188
- }
189
-
190
- &.right-slot {
191
- .input-button {
192
- border-left: var(--form-element-border-width) solid var(--theme-border);
193
- }
194
- }
195
- }
190
+ .input-text-slot {
191
+ margin-inline: 0;
192
+ }
193
+
194
+ .input-text-slot-left .input-button {
195
+ border-inline-end: var(--form-element-border-width) solid var(--input-text-border, var(--theme-border));
196
+ }
197
+
198
+ .input-text-slot-right .input-button {
199
+ border-inline-start: var(--form-element-border-width) solid var(--input-text-border, var(--theme-border));
196
200
  }
197
201
 
198
- .input-text-core.input-text-as-number {
202
+ .input-text {
199
203
  flex-grow: initial;
200
204
  text-align: center;
201
205
  width: v-bind(minLength);