srcdev-nuxt-components 9.4.10 → 9.4.12

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 (94) hide show
  1. package/.claude/commands/migrate-component.md +18 -0
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/output.html +1 -1
  4. package/.claude/skills/components/input-number-core.md +214 -0
  5. package/.claude/skills/components/input-select-core.md +151 -0
  6. package/.claude/skills/components/input-text-core.md +16 -0
  7. package/.claude/skills/components/input-textarea-core.md +154 -0
  8. package/.claude/skills/components/select-menu.md +195 -0
  9. package/.claude/skills/components/toggle-switch-core.md +150 -0
  10. package/.claude/skills/index.md +5 -0
  11. package/.vscode/srcdev-component-input-number.code-snippets +65 -0
  12. package/.vscode/srcdev-component-input-select.code-snippets +42 -0
  13. package/.vscode/srcdev-component-input-textarea.code-snippets +40 -0
  14. package/.vscode/srcdev-component-select-menu.code-snippets +65 -0
  15. package/.vscode/srcdev-component-toggle-switch.code-snippets +49 -0
  16. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +1 -1
  17. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +1 -1
  18. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +1 -1
  19. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +1 -1
  20. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +1 -1
  21. package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +1 -1
  22. package/app/components/01.atoms/animations/section-parallax/SectionParallax.vue +1 -1
  23. package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +2 -2
  24. package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +2 -2
  25. package/app/components/01.atoms/card/CardCore.vue +4 -4
  26. package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +2 -2
  27. package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +2 -2
  28. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +2 -2
  29. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +2 -2
  30. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +1 -1
  31. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +1 -1
  32. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +1 -1
  33. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +8 -8
  34. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +8 -8
  35. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +6 -6
  36. package/app/components/01.atoms/pop-over/PopOver.vue +6 -6
  37. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +1 -1
  38. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -1
  39. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +1 -1
  40. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +1 -1
  41. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +11 -0
  42. package/app/components/02.molecules/pricing-card/PricingCard.vue +3 -1
  43. package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +2 -0
  44. package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +13 -0
  45. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +168 -0
  46. package/app/components/02.molecules/select-menu/SelectMenu.vue +384 -0
  47. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +359 -0
  48. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +476 -0
  49. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +42 -0
  50. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +3 -3
  51. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +3 -3
  52. package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +18 -18
  53. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +2 -7
  54. package/app/components/05.forms/input-button/InputButtonCore.vue +1 -5
  55. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +26 -6
  56. package/app/components/05.forms/input-number/InputNumberCore.vue +125 -85
  57. package/app/components/05.forms/input-number/stories/InputNumberCore.stories.ts +92 -0
  58. package/app/components/05.forms/input-number/tests/InputNumberCore.spec.ts +119 -0
  59. package/app/components/05.forms/input-number/variants/InputNumberDefault.vue +30 -22
  60. package/app/components/05.forms/input-number/variants/stories/InputNumberDefault.stories.ts +123 -0
  61. package/app/components/05.forms/input-number/variants/tests/InputNumberDefault.spec.ts +143 -0
  62. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +2 -2
  63. package/app/components/05.forms/input-range/InputRangeCore.vue +2 -2
  64. package/app/components/05.forms/input-select/InputSelectCore.vue +23 -30
  65. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +2 -2
  66. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +115 -0
  67. package/app/components/05.forms/input-select/tests/InputSelectCore.spec.ts +129 -0
  68. package/app/components/05.forms/input-select/variants/InputSelectWithLabel.vue +1 -1
  69. package/app/components/05.forms/input-select/variants/tests/InputSelectWithLabel.spec.ts +87 -0
  70. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +14 -0
  71. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +1 -1
  72. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +55 -0
  73. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +24 -16
  74. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +1 -1
  75. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +119 -0
  76. package/app/components/05.forms/input-textarea/variants/InputTextareaWithLabel.vue +3 -13
  77. package/app/components/05.forms/input-textarea/variants/tests/InputTextareaWithLabel.spec.ts +93 -0
  78. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +95 -10
  79. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +29 -0
  80. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +120 -119
  81. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +1 -1
  82. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -1
  83. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +2 -2
  84. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +101 -0
  85. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabel.vue +3 -5
  86. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabelInline.vue +1 -2
  87. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabel.spec.ts +77 -0
  88. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabelInline.spec.ts +66 -0
  89. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +4 -4
  90. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +6 -6
  91. package/app/types/components/index.ts +1 -0
  92. package/app/types/components/select-menu.d.ts +5 -0
  93. package/package.json +1 -1
  94. package/.vscode/srcdev-component-toggle-switch-core.code-snippets +0 -13
@@ -0,0 +1,214 @@
1
+ # InputNumberCore Component
2
+
3
+ ## Overview
4
+
5
+ `InputNumberCore` is the low-level native `<input type="number">` primitive for the `05.forms`
6
+ number-input family. It renders a single native number input with left/right button slots (for
7
+ step-down/step-up controls). It has no label, description, or error-message rendering of its
8
+ own — that's composed by the `InputNumberDefault` variant below.
9
+
10
+ Most consumers should reach for **InputNumberDefault** rather than `InputNumberCore` directly.
11
+
12
+ ---
13
+
14
+ ## Props reference
15
+
16
+ > **Hyphenation rule**: Vue's ESLint config enforces `vue/attribute-hyphenation`. Always write camelCase prop names hyphenated in templates: `:field-has-error`, `:style-class-passthrough`, `:aria-describedby`.
17
+
18
+ | Prop (template form) | Type | Default | Notes |
19
+ |------|------|---------|-------|
20
+ | `:id` | `string` | (required) | Applied to the native input. |
21
+ | `:name` | `string` | (required) | Applied to the native input. |
22
+ | `:min` | `number` | (required) | Native `min`. |
23
+ | `:max` | `number` | (required) | Native `max`. |
24
+ | `:step` | `number` | `1` | Native `step`. |
25
+ | `:placeholder` | `string` | `""` | Native `placeholder`. |
26
+ | `:required` | `boolean` | `false` | Native `required`. |
27
+ | `:theme` | `FormUiTheme` | `"default"` | Sets `data-theme` on the wrapper. |
28
+ | `:weight` | `FormWeight` | `"normal"` | Adds an `input-number--{weight}` class to the input, for prop-shape consistency with sibling `05.forms` components. **No CSS in the library currently styles this class — it's a no-op today**, same convention as `InputRangeCore`'s `weight` prop. |
29
+ | `:input-variant` | `InputUiVariant` | `"normal"` | Adds the variant name as a class on the wrapper — `"normal"` (bordered box) and `"underlined"` (bottom-border only) have CSS; `"outlined"` doesn't (see CONSUMER-STYLING.md). Same prop/behaviour as `InputTextCore`. |
30
+ | `:field-has-error` | `boolean` | `false` | Drives `data-invalid` on the wrapper. |
31
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the input element. |
32
+ | `:aria-describedby` | `string` | `""` | Forwarded to the native input. |
33
+
34
+ ### v-model
35
+
36
+ `v-model` — `number | readonly number[]`, **required** (`defineModel({ required: true })`). Bound
37
+ directly to the native number input. Required rather than defaulted deliberately — same reasoning
38
+ as `InputRangeCore`'s `v-model` (see that skill doc) — this also satisfies `vue/require-default-prop`.
39
+
40
+ ---
41
+
42
+ ## Slots
43
+
44
+ | Slot | Notes |
45
+ |------|-------|
46
+ | `left` | Rendered before the input (e.g. a step-down button). Adds `has-left-slot` to the wrapper. |
47
+ | `right` | Rendered after the input (e.g. a step-up button). Adds `has-right-slot` to the wrapper. |
48
+
49
+ Any `.input-button-core` inside `.slot` gets its own border/outline stripped and is sized to
50
+ match the input (`aspect-ratio: 1`, `width: var(--input-min-height)`) regardless of which slot
51
+ it's in. The divider border is then added directly to that button element specifically when its
52
+ slot is present — `&.has-left-slot .left-slot .input-button-core { border-right: ... }` and the
53
+ right-hand mirror — so it sits flush against the button's own edge rather than around the
54
+ containing `.slot` div. This makes step buttons read as embedded segments of the same control
55
+ rather than separate floating buttons. The divider reuses the wrapper's own border colour token
56
+ (no separate divider token), same as `InputTextCore`/`InputTextAsNumberWithLabel`.
57
+
58
+ ---
59
+
60
+ ## CSS token API
61
+
62
+ See [CONSUMER-STYLING.md](../../app/components/05.forms/input-number/CONSUMER-STYLING.md) for the
63
+ full table with defaults.
64
+
65
+ **Common tokens:**
66
+ - `--input-number-surface` / `-surface-hover` — wrapper/input/embedded-button background, resting and hover, falls back to `--theme-input-surface`/`-surface-hover`
67
+ - `--input-number-border` / `-border-hover` / `-border-focus` — border colour resting, mouse-hover outline, and `:focus-visible` outline (also the divider colour and the embedded button's own focus outline), falls back to `--theme-border`/`--theme-border-focus`
68
+ - `--input-number-text-color` — input text colour, falls back to `--theme-input-text-color-normal`
69
+ - `--input-number-placeholder-color` — placeholder text colour, falls back to `--theme-input-placeholder`
70
+
71
+ ---
72
+
73
+ ## Fixed 2026-09-22: dead CSS from a missing base class and mismatched selectors
74
+
75
+ `InputNumberCore` migrated to full compliance from a `variants:true`, `eslint_issues:true`,
76
+ score-1/5 state. Several latent bugs were fixed alongside the standard checklist:
77
+
78
+ - The native `<input>` had no static `input-number-core` class — only the dynamic
79
+ `elementClasses` passthrough — so the entire `.input-number-core { ... }` style block (font,
80
+ padding, colour, focus box-shadow) never matched anything. Fixed by adding the class directly.
81
+ - `placeholder` was declared as a prop but never bound on the `<input>` — unlike `InputRangeCore`
82
+ (where a range input genuinely has no placeholder concept and the no-op is intentional and
83
+ documented), a number input *does* support `placeholder` meaningfully, so this was a real gap,
84
+ not a deliberate no-op. Fixed by binding `:placeholder`.
85
+ - The left/right slot divs rendered with classes `slot left`/`slot right`, but the CSS targeted
86
+ `.left-slot`/`.right-slot` and `.has-left-slot`/`.has-right-slot` — neither the slot divs nor
87
+ the wrapper ever carried those exact class names, so the divider-border and flex-alignment
88
+ rules for embedded step buttons never applied. Fixed by renaming the slot divs to
89
+ `slot left-slot`/`slot right-slot` and adding `has-left-slot`/`has-right-slot` to the wrapper
90
+ directly from `slots.left`/`slots.right` (Vue's own `useSlots()`), rather than a fragile,
91
+ cross-component class-toggling relay.
92
+ - Relatedly, `InputNumberDefault` called `updateElementClasses(["has-left-button",
93
+ "has-right-button"])` unconditionally on mount, which permanently added those two classes to
94
+ its own outer `.input-number-with-label` wrapper — a different element than
95
+ `InputNumberCore`'s `.input-number-wrapper`, so the `:has()` selector that was meant to key off
96
+ those classes could never match (the class lived on an ancestor, not a descendant). Replaced
97
+ entirely: `InputNumberCore` now detects an embedded button directly via
98
+ `:has(.left-slot .input-button-core)` / `:has(.right-slot .input-button-core)`, so no
99
+ JS-driven class relay is needed at all.
100
+ - `theme` was typed as an inline `"default" | "success" | "error" | "warning"` union in both
101
+ `InputNumberCore` and `InputNumberDefault` instead of importing the shared `FormUiTheme` type —
102
+ aligned with the rest of `05.forms`.
103
+ - `weight` was passed from `InputNumberDefault` to `InputNumberCore` but `InputNumberCore` had no
104
+ matching prop, so it fell through as a raw, meaningless `weight="normal"` HTML attribute on the
105
+ wrapper `<div>`. Fixed by declaring the prop on `InputNumberCore` too, matching `InputRangeCore`.
106
+ - `defineModel()` had no default, tripping `vue/require-default-prop` — fixed the same way as
107
+ `InputRangeCore`: `defineModel({ required: true })`, not a rule disable or an artificial default.
108
+ - A single-use private token, `--_border`, was inlined to the public
109
+ `var(--input-number-border, var(--theme-border))` directly at its point of use — it had no
110
+ composition or state-swap behind it, just a bare pass-through (see pitfall #20). `--_surface`
111
+ (used twice) and `--_min-width` (composed from a `v-bind()` value) correctly stayed private.
112
+
113
+ ---
114
+
115
+ ## Fixed 2026-09-22 (follow-up): rewritten to visually mirror InputTextCore
116
+
117
+ The first migration pass above only made `InputNumberCore`'s *own* (pre-existing, previously dead)
118
+ CSS active — it didn't reconsider whether that CSS was actually the right design. Once live, it
119
+ was visually inconsistent with the rest of `05.forms`: no border-radius, no hover/focus outline
120
+ state, wrong padding-token family (`--form-textarea-padding-*` instead of the shared input
121
+ padding tokens), and a box-shadow-toggle focus mechanism instead of the `:has()`-based outline
122
+ approach every other input in this library uses. `InputNumberCore` is meant to be a native
123
+ `<input type="number">` sibling to the text-based `InputTextAsNumberWithLabel` variant (see
124
+ `input-text-core.md`'s Variants section) — both should look and behave the same. The CSS was rewritten
125
+ to mirror `InputTextCore` directly:
126
+
127
+ - Added `:input-variant` (`"normal"` / `"outlined"` / `"underlined"`), matching `InputTextCore`'s
128
+ prop and wrapper-class shape (`InputNumberDefault` forwards it the same way
129
+ `InputTextWithLabel` forwards it to `InputTextCore`, including to `InputLabel`).
130
+ - Replaced the flat, unconditional border/radius/box-shadow with `InputTextCore`'s `.normal`
131
+ variant shape: border + `border-radius: var(--form-input-border-radius)` (previously this read
132
+ `var(--form-element-border-width)` — a pre-existing bug that used the *border-width* token as
133
+ the *radius*, coincidentally invisible while the block was dead), plus `:has(input:hover)` /
134
+ `:has(input:focus-visible)` outline states instead of the old `--_focus-box-shadow` custom
135
+ property toggle.
136
+ - `--input-number-border-hover` and `--input-number-border-focus` are new public tokens for
137
+ these two states (both fall back to `--theme-border-focus`, same as `InputTextCore`).
138
+ - Padding moved from `--form-textarea-padding-*` (the wrong token family — textarea, not input)
139
+ to the shared `--input-padding-block`/`--input-padding-inline`/`--input-min-height` tokens every
140
+ other `05.forms` text-like input uses.
141
+ - The embedded step button now gets a hover background (`--input-number-surface-hover`, new
142
+ public token) and a visible `:focus-visible` outline (reusing `--input-number-border-focus`) —
143
+ previously it had neither, an accessibility gap beyond the visual mismatch (checklist item 9).
144
+ - The divider between the input and an embedded button now reuses the border colour
145
+ (`--_border`) directly instead of a separate `--input-number-divider-color` token — removed,
146
+ since `InputTextCore` doesn't have an equivalent separate token either (the border colour
147
+ *is* the divider colour there too).
148
+ - `--_border-hover` and `--_surface-hover` were each used only once with no composition or
149
+ state-swap, so per the public-token rule they were inlined directly
150
+ (`var(--input-number-border-hover, var(--theme-border-focus))` /
151
+ `var(--input-number-surface-hover, var(--theme-input-surface-hover))`) rather than kept as
152
+ private wrappers. `--_border-focus` stayed private since it's now genuinely reused (the
153
+ wrapper's own focus outline *and* the embedded button's focus outline both read it).
154
+
155
+ **Separately found in the same investigation** (not an `InputNumberCore` bug, but what the visual
156
+ comparison was originally checking against): `InputTextAsNumberWithLabel.vue` had its own,
157
+ unrelated pre-existing bug — see `input-text-core.md`'s Variants section for the fix.
158
+
159
+ **Correction, same day**: the first version of this rewrite still didn't visually match — the
160
+ divider border and button sizing (`aspect-ratio: 1`, `width: var(--input-min-height)`) had been
161
+ put on the `.left-slot`/`.right-slot` *wrapper divs*, not on `.input-button-core` itself, unlike
162
+ `InputTextCore`'s equivalent block. That left an unstyled gap between the button and the border
163
+ line instead of a flush edge, and the buttons weren't sized to match the input's height. Fixed by
164
+ moving the divider border and all button sizing/colour rules onto `.input-button-core` directly
165
+ (`&.has-left-slot .left-slot .input-button-core { border-right: ... }`, matching
166
+ `InputTextAsNumberWithLabel`'s `.left-slot .input-button-core { border-right: ... }` exactly)
167
+ rather than the containing `.slot` div. When mirroring another component's CSS structure, check
168
+ *which element* a rule targets, not just that an equivalent rule exists somewhere in the block —
169
+ a border/sizing rule one level off in the DOM tree produces a visually-close-but-not-matching
170
+ result that's easy to miss without a side-by-side screenshot comparison.
171
+
172
+ ---
173
+
174
+ ## Variants
175
+
176
+ ### InputNumberDefault
177
+
178
+ `InputNumberDefault` (`variants/InputNumberDefault.vue`) composes `InputNumberCore` with
179
+ `InputLabel`, `InputDescription`, and `InputError`, plus optional step-down/step-up buttons
180
+ (`InputButtonCore`) wired into the `left`/`right` slots.
181
+
182
+ **Additional props over InputNumberCore:**
183
+
184
+ | Prop (template form) | Type | Default | Notes |
185
+ |------|------|---------|-------|
186
+ | `:label` | `string` | (required) | Rendered via `InputLabel`. |
187
+ | `:error-message` | `object \| string` | (required) | Rendered via `InputError` when `field-has-error` is true. |
188
+ | `:step-down-label` | `string` | `"Step down"` | `button-text` on the step-down `InputButtonCore` — override for localisation. |
189
+ | `:step-up-label` | `string` | `"Step up"` | `button-text` on the step-up `InputButtonCore` — override for localisation. |
190
+
191
+ **Slots**: `descriptionHtml`, `descriptionText` (both forwarded to `InputDescription`), plus
192
+ `left`/`right` (forwarded straight through to the underlying `InputNumberCore` — the
193
+ step-down/step-up buttons only render when you use the `left`/`right` slots yourself, since the
194
+ button icon content itself is consumer-supplied).
195
+
196
+ ```vue
197
+ <InputNumberDefault
198
+ name="quantity"
199
+ label="Quantity"
200
+ v-model="quantity"
201
+ :min="1"
202
+ :max="10"
203
+ error-message="Please choose a value between 1 and 10"
204
+ :field-has-error="hasError"
205
+ >
206
+ <template #left><Icon name="mdi:minus" /></template>
207
+ <template #right><Icon name="mdi:plus" /></template>
208
+ </InputNumberDefault>
209
+ ```
210
+
211
+ `v-model` is `number | readonly number[]`, **required** — same as `InputNumberCore` above.
212
+
213
+ Step-down is disabled (`readonly` on its `InputButtonCore`) once the value reaches `min`, and
214
+ step-up once it reaches `max`; clicking either button while at that boundary is a no-op.
@@ -0,0 +1,151 @@
1
+ # InputSelectCore Component
2
+
3
+ ## Overview
4
+
5
+ `InputSelectCore` is the low-level native `<select>` primitive for the `05.forms` select family.
6
+ It renders a native select with a data-driven list of `<option>`s (each optionally decorated with
7
+ an icon), an optional placeholder option, and adopts the modern CSS Customizable Select API
8
+ (`appearance: base-select`) for a fully styleable open-picker state where supported, with a
9
+ graceful CSS fallback (see "Browser support" below). It has no label, description, or
10
+ error-message rendering of its own — that's composed by the `InputSelectWithLabel` variant below.
11
+
12
+ Most consumers should reach for **InputSelectWithLabel** rather than `InputSelectCore` directly.
13
+
14
+ ---
15
+
16
+ ## Props reference
17
+
18
+ > **Hyphenation rule**: Vue's ESLint config enforces `vue/attribute-hyphenation`. Always write camelCase prop names hyphenated in templates: `:field-has-error`, `:style-class-passthrough`, `:aria-describedby`.
19
+
20
+ | Prop (template form) | Type | Default | Notes |
21
+ |------|------|---------|-------|
22
+ | `:id` | `string` | (required) | Applied to the native select. |
23
+ | `:name` | `string` | (required) | Applied to the native select. |
24
+ | `:placeholder` | `string` | `""` | Renders a disabled placeholder `<option>` (selected when `modelValue` is empty). No option rendered at all when unset. |
25
+ | `:title` | `string` | `"Please select an option"` | Native `title` attribute (tooltip). |
26
+ | `:required` | `boolean` | `false` | Native `required`. |
27
+ | `:theme` | `FormUiTheme` | `"default"` | Sets `data-theme` on the wrapper. |
28
+ | `:input-variant` | `InputUiVariant` | `"normal"` | Adds the variant name as a class on the wrapper — `"normal"` (bordered box) and `"underlined"` (bottom-border only) have CSS; `"outlined"` doesn't (see CONSUMER-STYLING.md). Same prop/behaviour as `InputTextCore`. |
29
+ | `:field-has-error` | `boolean` | `false` | Drives `data-invalid` on the wrapper and `aria-invalid` on the select. |
30
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the select element. |
31
+ | `:aria-describedby` | `string` | `""` | Forwarded to the native select. |
32
+
33
+ ### v-models
34
+
35
+ - `v-model` — `string | number | readonly number[]`, **required** (`defineModel({ required: true })`, satisfying `vue/require-default-prop` — see `project_ledger_eslint_issues_column` in memory).
36
+ - `v-model:field-data` — `IFormMultipleOptions` (`{ data: IOptionsConfig[]; total; skip; limit }`), **required**. The `data` array drives the rendered `<option>`s. The template dereferences `fieldData.data` unconditionally, so a consumer that doesn't bind `v-model:field-data` will crash on render — `required: true` documents that constraint (it's a dev-time warning only, not a runtime guard).
37
+ - `v-model:is-dirty` — `boolean`. Set to `true` on the native `change` event (never reset to `false` by this component).
38
+ - `v-model:is-active` — `boolean`. Tracks native focus/blur (`focusin`/`focusout`), toggling `.active` on the wrapper.
39
+
40
+ ---
41
+
42
+ ## CSS token API
43
+
44
+ See [CONSUMER-STYLING.md](../../app/components/05.forms/input-select/CONSUMER-STYLING.md) for the
45
+ full table with defaults.
46
+
47
+ **Common tokens:**
48
+ - `--input-select-surface` / `-surface-hover` — wrapper/select background and option-row hover background, fall back to `--theme-input-surface`/`-surface-hover`
49
+ - `--input-select-border` / `-border-hover` / `-border-focus` — border colour resting, mouse-hover outline, and `:focus-visible` outline (also the open-picker's own border/outline), fall back to `--theme-border`/`--theme-border-focus`
50
+
51
+ ---
52
+
53
+ ## Browser support: `appearance: base-select`
54
+
55
+ The open-picker styling (`&::picker(select)`, `:open`/`:not(:open)` states, the transition/opacity
56
+ `@starting-style` reveal) relies on the CSS Customizable Select API
57
+ (`appearance: base-select`/`::picker(select)`), a very new feature not yet supported everywhere.
58
+ The component degrades gracefully in non-supporting browsers rather than breaking: `appearance:
59
+ none` is set first (unconditionally), then overridden by `appearance: base-select` only where the
60
+ browser recognizes that value — an unsupported value is simply ignored by the CSS parser, so the
61
+ select falls back to the browser's native (unstyled) dropdown instead of losing functionality.
62
+ Per pitfall #19's general lesson (checking WebKit support specifically for load-bearing use of a
63
+ very new CSS feature, since Chrome's device-emulation mode can't catch a WebKit-only gap): this
64
+ fallback is CSS-only degradation, not a functional break like `ExpandingPanel`'s `::details-content`
65
+ case was, so it's lower risk — but the open-picker's custom border/outline/transition styling
66
+ should still be spot-checked on a real Safari session before treating it as visually final.
67
+
68
+ ---
69
+
70
+ ## Fixed 2026-09-22: dead functionality and prop-shape bugs
71
+
72
+ `InputSelectCore` migrated to full compliance from a `variants:true`, `eslint_issues:true`,
73
+ score-2/5 state (no tests, no skill doc, no snippet).
74
+
75
+ - **`required` prop was declared but never bound to the native `<select>`** — native HTML
76
+ `required` validation was completely inert regardless of the prop's value. Fixed by adding
77
+ `:required` to the select.
78
+ - **`styleClassPassthrough` prop was declared but never consumed** — no `useStyleClassPassthrough`
79
+ call existed anywhere in the script, and the select's `class` was a hardcoded static string.
80
+ The prop was entirely dead; a consumer passing it had no effect. Fixed by wiring up
81
+ `useStyleClassPassthrough` and binding `elementClasses` alongside the static class.
82
+ - **`isDirty`/`isActive` models were declared and read in the wrapper's CSS class bindings
83
+ (`{ dirty: isDirty }`, `{ active: isActive }`), but nothing ever set them** — no `focusin`/
84
+ `focusout`/`change` handlers existed at all, unlike `InputTextareaCore`'s `updateFocus`
85
+ pattern. The `.dirty`/`.active` CSS states could never trigger. Fixed by adding
86
+ `@focusin`/`@focusout` (toggling `isActive`) and `@change` (setting `isDirty = true`) directly
87
+ on the select.
88
+ - **The placeholder `<option>` used `readonly`**, which is not a valid/supported HTML attribute
89
+ on `<option>` — a silent no-op, leaving the placeholder fully selectable as a real answer
90
+ (defeating its purpose). Fixed by switching to `disabled`, the correct native pattern for a
91
+ non-selectable placeholder option.
92
+ - **`theme`/`inputVariant` were typed as inline unions** (`"default" | "success" | "error" |
93
+ "warning"` / `"normal" | "outlined" | "underlined"`) instead of importing the shared
94
+ `FormUiTheme`/`InputUiVariant` types — aligned with the rest of `05.forms`.
95
+ - **`fieldData`'s `defineModel` used an untyped `defineModel("fieldData") as Ref<T>` cast**
96
+ instead of the proper generic `defineModel<T>("fieldData")` call, and had no default — the
97
+ `as` cast also hid the prop from ESLint's `vue/require-default-prop` detection entirely (it
98
+ didn't fire on `InputSelectCore.vue`, only on `InputSelectWithLabel.vue`'s correctly-typed
99
+ equivalent). Fixed by using the proper generic form with `{ required: true }` on both files —
100
+ see the `v-model:field-data` note above for why `required` is the meaningful fix here (the
101
+ template would otherwise crash on `fieldData.data` with no data bound).
102
+ - **Dead commented-out CSS** (~8 lines, a "legacy support - eg, Safari" `::after` arrow
103
+ fallback that was never active) and a **redundant duplicate declaration**
104
+ (`&.underlined { background-color: var(--_surface); }`, re-setting a value the unscoped base
105
+ rule already applied unconditionally) were removed.
106
+ - **CSS token cleanup**: `--_surface`, `--_surface-hover`, `--_border-hover`, and
107
+ `--_outline-color` were each used at exactly one point with no composition or state-swap
108
+ behind them (`--_outline-color` was a bare alias for `--_border-focus`, per pitfall #20) — all
109
+ four inlined directly to their public `var(--input-select-*, {default})` form at their point of
110
+ use. `--_border` (reused across `.normal`/`.underlined`/open-picker) and `--_border-focus`
111
+ (used directly once, plus now the open-picker's outline too) correctly stayed private.
112
+
113
+ ---
114
+
115
+ ## Variants
116
+
117
+ ### InputSelectWithLabel
118
+
119
+ `InputSelectWithLabel` (`variants/InputSelectWithLabel.vue`) composes `InputSelectCore` with
120
+ `InputLabel`, `InputDescription` (positioned before or after the field depending on
121
+ `inputVariant === "outlined"`, matching `InputTextWithLabel`'s exact placement rule), and
122
+ `InputError`.
123
+
124
+ **Additional props over InputSelectCore:**
125
+
126
+ | Prop (template form) | Type | Default | Notes |
127
+ |------|------|---------|-------|
128
+ | `:data-testid` | `string` | `"input-select-with-label"` | Sets `data-testid` on the wrapper — used for the wrapper element specifically (`:data-testid` shorthand resolves to the camelCase `dataTestid` prop, same convention as `:aria-describedby`). |
129
+ | `:label` | `string` | (required) | Rendered via `InputLabel`. |
130
+ | `:error-message` | `object \| string` | (required) | Rendered via `InputError` when `field-has-error` is true. |
131
+
132
+ **Slots**: `descriptionHtml`, `descriptionText` (both forwarded to `InputDescription`).
133
+
134
+ ```vue
135
+ <InputSelectWithLabel
136
+ name="colour"
137
+ label="Favourite colour"
138
+ v-model="colour"
139
+ v-model:field-data="colourOptions"
140
+ placeholder="Choose a colour"
141
+ :error-message="errors.colour"
142
+ :field-has-error="!!errors.colour"
143
+ >
144
+ <template #descriptionText>Pick the colour you like best</template>
145
+ </InputSelectWithLabel>
146
+ ```
147
+
148
+ `v-model` and `v-model:field-data` are both **required** — same as `InputSelectCore` above. Note
149
+ this wrapper passes `:theme="FormUiTheme"` (a local `computed`, not the raw `theme` prop) down to
150
+ `InputSelectCore`, so the theme automatically switches to `"error"` whenever `fieldHasError` is
151
+ true, regardless of the `theme` prop's own value.
@@ -163,6 +163,22 @@ Extra props: `min: number` (required), `max: number` (required), `step?: number`
163
163
  `stepDownText`/`stepUpText` (default `"Step down"`/`"Step up"`) — the decrement/increment buttons'
164
164
  accessible names. Requires `left`/`right` slot content for the step icons (no default icon).
165
165
 
166
+ **Fixed 2026-09-22**: `updateElementClasses(["input-text-as-number", "has-left-button",
167
+ "has-right-button"])` computes marker classes onto this component's own `elementClasses`, which is
168
+ bound to the outer `.input-text-with-label` wrapper — but the nested `<InputTextCore>` was passed
169
+ `:style-class-passthrough` as a bare shorthand, which resolves to the raw incoming
170
+ `styleClassPassthrough` **prop**, not the local `elementClasses`. So the marker classes never
171
+ reached the actual `<input>`, the scoped `.input-text-core.input-text-as-number { width:
172
+ fit-content; text-align: center; ... }` CSS never matched anything, and the input silently fell
173
+ back to `InputTextCore`'s default full-width, left-aligned styling instead of the intended
174
+ compact, centered numeric stepper box. Fixed by changing that binding to
175
+ `:style-class-passthrough="elementClasses"` — `elementClasses` still carries any consumer-supplied
176
+ `styleClassPassthrough` too, since the composable seeds it from the prop before the marker classes
177
+ are toggled on top. When a component computes its own marker/state classes via
178
+ `updateElementClasses()` and needs them on a *child* component rather than its own root element,
179
+ always pass the computed `elementClasses` explicitly — the `:x` shorthand only ever resolves to
180
+ the prop of that exact name, never a same-named local composable output.
181
+
166
182
  ---
167
183
 
168
184
  ## Accessibility
@@ -0,0 +1,154 @@
1
+ # InputTextareaCore Component
2
+
3
+ ## Overview
4
+
5
+ `InputTextareaCore` is the low-level native `<textarea>` primitive for the `05.forms` multi-line
6
+ text family. It renders a single native textarea with left/right decorative slots. It has no
7
+ label, description, or error-message rendering of its own — that's composed by the
8
+ `InputTextareaWithLabel` variant below.
9
+
10
+ Most consumers should reach for **InputTextareaWithLabel** rather than `InputTextareaCore`
11
+ directly.
12
+
13
+ ---
14
+
15
+ ## Props reference
16
+
17
+ > **Hyphenation rule**: Vue's ESLint config enforces `vue/attribute-hyphenation`. Always write camelCase prop names hyphenated in templates: `:field-has-error`, `:style-class-passthrough`, `:aria-describedby`.
18
+
19
+ | Prop (template form) | Type | Default | Notes |
20
+ |------|------|---------|-------|
21
+ | `:id` | `string` | (required) | Applied to the native textarea. |
22
+ | `:name` | `string` | (required) | Applied to the native textarea. |
23
+ | `:maxlength` | `number` | `255` | Native `maxlength`. |
24
+ | `:placeholder` | `string` | `""` | Native `placeholder`. |
25
+ | `:required` | `boolean` | `false` | Native `required`. |
26
+ | `:theme` | `FormUiTheme` | `"default"` | Sets `data-theme` on the wrapper. |
27
+ | `:input-variant` | `InputUiVariant` | `"normal"` | Adds the variant name as a class on the wrapper — `"normal"` (bordered box) and `"underlined"` (bottom-border only) have CSS; `"outlined"` doesn't (see CONSUMER-STYLING.md). Same prop/behaviour as `InputTextCore`. |
28
+ | `:field-has-error` | `boolean` | `false` | Drives `data-invalid` on the wrapper and `aria-invalid` on the textarea. |
29
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the textarea element. |
30
+ | `:aria-describedby` | `string` | `""` | Forwarded to the native textarea. |
31
+
32
+ ### v-models
33
+
34
+ - `v-model` — `string | number | readonly string[] | null | undefined`, **required**
35
+ (`defineModel({ required: true })`, satisfying `vue/require-default-prop` the same way as
36
+ `InputRangeCore`/`InputNumberCore` — see `project_ledger_eslint_issues_column` in memory).
37
+ "Required" here means a consumer must bind `v-model`, not that the value can't be an empty
38
+ string — `""` is a perfectly valid textarea value.
39
+ - `v-model:is-dirty` — `boolean`. Set once by `InputTextareaWithLabel` the first time the value
40
+ becomes non-empty (never reset back to `false` by this component).
41
+ - `v-model:is-active` — `boolean`. Tracks native focus/blur (`focusin`/`focusout`), toggling
42
+ `.active` on the wrapper and textarea.
43
+
44
+ ---
45
+
46
+ ## Slots
47
+
48
+ | Slot | Notes |
49
+ |------|-------|
50
+ | `left` | Rendered before the textarea (decorative — e.g. an icon). Adds `has-left-slot` to the wrapper. |
51
+ | `right` | Rendered after the textarea (decorative). Adds `has-right-slot` to the wrapper. |
52
+
53
+ Unlike `InputNumberCore`/`InputRangeCore`, these slots have no built-in `InputButtonCore`
54
+ divider/embedded-button styling — they're plain decorative content (see the Storybook stories,
55
+ which use emoji), not a stepper control.
56
+
57
+ ---
58
+
59
+ ## CSS token API
60
+
61
+ See [CONSUMER-STYLING.md](../../app/components/05.forms/input-textarea/CONSUMER-STYLING.md) for
62
+ the full table with defaults.
63
+
64
+ **Common tokens:**
65
+ - `--input-textarea-surface` — wrapper background, falls back to `--theme-input-surface`
66
+ - `--input-textarea-border` / `-border-hover` / `-border-focus` — border colour resting, mouse-hover outline, and `:focus-visible` outline, fall back to `--theme-border`/`--theme-border-focus`
67
+ - `--input-textarea-text-color` — textarea text colour, falls back to `--theme-input-text-color-normal`
68
+ - `--input-textarea-placeholder-color` — placeholder text colour, falls back to `--theme-input-placeholder`
69
+
70
+ ---
71
+
72
+ ## Fixed 2026-09-22: undefined tokens and dead code
73
+
74
+ `InputTextareaCore` migrated to full compliance from a `variants:true`, `eslint_issues:true`,
75
+ score-1/5 state (no public tokens, no CONSUMER-STYLING.md, no tests, no skill doc, no snippet).
76
+
77
+ - **Silently broken styling from undefined CSS custom properties** (same bug class as pitfall #21
78
+ and `InputRangeCore`'s dead `--theme-form-range-accent-color`): the wrapper's `padding-inline`
79
+ read `--element-decorator-padding-inline`, and the placeholder's `font-size`/`font-style`/
80
+ `line-height`/`font-weight` read `--input-placeholder-font-size`/`-font-style`/`-line-height`/
81
+ `-font-weight` — **none of these five custom properties were declared anywhere in the
82
+ codebase**. Per the CSS spec, an undefined `var()` with no fallback makes the whole declaration
83
+ invalid, so all five silently fell back to the browser/inherited default instead of the intended
84
+ design. Fixed by switching to the tokens `InputTextCore` actually uses for the same purpose:
85
+ `--input-padding-inline` for the wrapper padding, and `--theme-input-placeholder-font-size` +
86
+ hardcoded `italic`/`1`/`normal` for the placeholder (matching `InputTextCore`'s placeholder rule
87
+ exactly — those three placeholder sub-properties aren't tokenized there either).
88
+ - **Zero public `--input-textarea-*` tokens existed at all** — every colour value read a
89
+ `--theme-*` global directly, with no component-level override point (the exact gap pitfall #14
90
+ describes for early `05.forms` components). Added the full set matching `InputTextCore`'s shape:
91
+ `--input-textarea-surface`, `-border`, `-border-hover`, `-border-focus`, `-text-color`,
92
+ `-placeholder-color`.
93
+ - **Dead code**: both `InputTextareaCore` and `InputTextareaWithLabel` declared
94
+ `const FormUiTheme = computed(() => props.fieldHasError ? "error" : props.theme);` — a value
95
+ binding that shadows the imported `FormUiTheme` *type* import of the same name, and is never
96
+ referenced anywhere in either file (the template's `:theme` shorthand resolves straight from
97
+ `defineProps`). Removed both. `InputTextareaWithLabel` also had ~5 lines of commented-out dead
98
+ code (an old, hand-rolled `id`/`errorId`/`ariaDescribedby` implementation, superseded by
99
+ `useAriaDescribedById` right below it) — removed. (The identical dead `FormUiTheme` computed
100
+ still exists in `InputTextCore`/`InputTextWithLabel` themselves — out of scope for this pass
101
+ since those weren't being touched, but worth cleaning up next time either is migrated.)
102
+ - **`InputTextareaWithLabel` passed `:label` down to `InputTextareaCore`**, which has no `label`
103
+ prop (matching `InputTextCore`, which doesn't have one either) — it fell through as a
104
+ meaningless raw `label="..."` HTML attribute on the wrapper `<div>`. Removed the pass-through;
105
+ `InputLabel` (which does have a real `label` concept) already renders it correctly. Covered by
106
+ a regression test.
107
+ - **`defineModel()` had no default**, tripping `vue/require-default-prop` in both files — fixed
108
+ with `{ required: true }`, same pattern as `InputRangeCore`/`InputNumberCore` (see the v-model
109
+ note above for why this is still correct even though an empty string is a valid value here).
110
+ - **`InputTextareaWithLabel.stories.ts` used `reactive()` in two render functions
111
+ (`AllVariants`/`FormExamples`) without importing it** — only `computed` was imported from
112
+ `"vue"`. This library's `.ts` Storybook files need Vue reactivity APIs imported explicitly (no
113
+ Nuxt auto-import outside `.vue` files) — a real runtime `ReferenceError` waiting to happen the
114
+ first time either story rendered. Fixed by adding `reactive` to the existing `computed` import.
115
+
116
+ ---
117
+
118
+ ## Variants
119
+
120
+ ### InputTextareaWithLabel
121
+
122
+ `InputTextareaWithLabel` (`variants/InputTextareaWithLabel.vue`) composes `InputTextareaCore` with
123
+ `InputLabel`, `InputDescription` (positioned before or after the field depending on
124
+ `inputVariant === "outlined"`, matching `InputTextWithLabel`'s exact placement rule), and
125
+ `InputError`.
126
+
127
+ **Additional props over InputTextareaCore:**
128
+
129
+ | Prop (template form) | Type | Default | Notes |
130
+ |------|------|---------|-------|
131
+ | `:label` | `string` | (required) | Rendered via `InputLabel`. |
132
+ | `:error-message` | `object \| string` | (required) | Rendered via `InputError` when `field-has-error` is true. |
133
+
134
+ **Slots**: `descriptionHtml`, `descriptionText` (both forwarded to `InputDescription`), plus
135
+ `left`/`right` (forwarded straight through to the underlying `InputTextareaCore`).
136
+
137
+ ```vue
138
+ <InputTextareaWithLabel
139
+ name="message"
140
+ label="Message"
141
+ v-model="message"
142
+ placeholder="How can we help you?"
143
+ :maxlength="500"
144
+ :error-message="errors.message"
145
+ :field-has-error="!!errors.message"
146
+ >
147
+ <template #descriptionText>Please describe your inquiry in detail</template>
148
+ </InputTextareaWithLabel>
149
+ ```
150
+
151
+ `v-model` is `string | number | readonly string[] | null | undefined`, **required** — same as
152
+ `InputTextareaCore` above. `isDirty`/`isActive` are managed internally (not exposed as props) —
153
+ `isDirty` flips to `true` the first time the value becomes a non-empty string (checked on mount
154
+ and on every change), and never resets.