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
@@ -0,0 +1,182 @@
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputTextAsNumberField from "../InputTextAsNumberField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+
8
+ interface StoryArgs {
9
+ modelValue: number | undefined;
10
+ name: string;
11
+ label: string;
12
+ min: number;
13
+ max: number;
14
+ step: number;
15
+ placeholder: string;
16
+ errorMessage: string;
17
+ fieldHasError: boolean;
18
+ required: boolean;
19
+ theme: FormUiTheme;
20
+ inputVariant: InputUiVariant;
21
+ styleClassPassthrough: string[];
22
+ stepDownText: string;
23
+ stepUpText: string;
24
+ useStepButtons: boolean;
25
+ descriptionText: string;
26
+ }
27
+
28
+ const meta: Meta<StoryArgs> = {
29
+ title: "Components/Forms/Input Text/InputTextAsNumberField",
30
+ component: InputTextAsNumberField,
31
+ argTypes: {
32
+ modelValue: { control: "number", table: { category: "Model" } },
33
+ name: { control: "text", table: { category: "Basic" } },
34
+ label: { control: "text", table: { category: "Basic" } },
35
+ min: { control: "number", table: { category: "Basic" } },
36
+ max: { control: "number", table: { category: "Basic" } },
37
+ step: { control: "number", table: { category: "Basic" } },
38
+ placeholder: { control: "text", table: { category: "Basic" } },
39
+ errorMessage: { control: "text", table: { category: "Validation" } },
40
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
41
+ required: { control: "boolean", table: { category: "Validation" } },
42
+ theme: {
43
+ control: { type: "select" },
44
+ options: ["default", "success", "error", "warning"],
45
+ table: { category: "Styling" },
46
+ },
47
+ inputVariant: {
48
+ control: { type: "select" },
49
+ options: ["normal", "outlined", "underlined"],
50
+ table: { category: "Styling" },
51
+ },
52
+ styleClassPassthrough: { control: "object", table: { category: "Styling" } },
53
+ stepDownText: { control: "text", description: "Step-down button's accessible name", table: { category: "Accessibility" } },
54
+ stepUpText: { control: "text", description: "Step-up button's accessible name", table: { category: "Accessibility" } },
55
+ useStepButtons: {
56
+ control: "boolean",
57
+ description: "Story-only: render the step buttons by filling the left/right slots",
58
+ table: { category: "Slots" },
59
+ },
60
+ descriptionText: { control: "text", description: "Story-only: fills the descriptionText slot", table: { category: "Slots" } },
61
+ },
62
+ args: {
63
+ modelValue: 1,
64
+ name: "quantity",
65
+ label: "Quantity",
66
+ min: 0,
67
+ max: 10,
68
+ step: 1,
69
+ placeholder: "",
70
+ errorMessage: "",
71
+ fieldHasError: false,
72
+ required: false,
73
+ theme: "default",
74
+ inputVariant: "normal",
75
+ styleClassPassthrough: [],
76
+ stepDownText: "Step down",
77
+ stepUpText: "Step up",
78
+ useStepButtons: true,
79
+ descriptionText: "",
80
+ },
81
+ decorators: [
82
+ (story, context) => ({
83
+ components: { story, CanvasSwitcher },
84
+ setup() {
85
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
86
+ return { canvasName };
87
+ },
88
+ template: `
89
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
90
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
91
+ </div>
92
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
93
+ <story />
94
+ </div>
95
+ `,
96
+ }),
97
+ ],
98
+ parameters: {
99
+ layout: "fullscreen",
100
+ docs: {
101
+ description: {
102
+ component:
103
+ "A number field built on a text input (`InputText`), for a compact stepper without the native number spinner. `v-model` is a number, or `undefined` when empty. Filling the `left`/`right` slots adds step buttons that step by `step`, round away floating-point drift, clamp to `min`/`max` and start from 0, clamped, when empty. Typing is digits-only for whole, non-negative ranges; a fractional `step` switches to a decimal keypad, and a negative `min` to a text keyboard so a minus sign can be typed.",
104
+ },
105
+ },
106
+ },
107
+ };
108
+
109
+ export default meta;
110
+ type Story = StoryObj<StoryArgs>;
111
+
112
+ const render = (args: StoryArgs) => ({
113
+ components: { InputTextAsNumberField },
114
+ setup() {
115
+ const componentArgs = computed(() => {
116
+ const { modelValue: _modelValue, useStepButtons: _useStepButtons, descriptionText: _descriptionText, ...rest } = args;
117
+ return rest;
118
+ });
119
+ return { args, componentArgs };
120
+ },
121
+ template: `
122
+ <InputTextAsNumberField v-model="args.modelValue" v-bind="componentArgs">
123
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
124
+ <template v-if="args.useStepButtons" #left><Icon name="mdi:minus" /></template>
125
+ <template v-if="args.useStepButtons" #right><Icon name="mdi:plus" /></template>
126
+ </InputTextAsNumberField>
127
+ <p style="margin-block-start: 1.6rem; font-family: monospace;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
128
+ `,
129
+ });
130
+
131
+ export const Default: Story = { render };
132
+
133
+ export const WithoutStepButtons: Story = { args: { useStepButtons: false }, render };
134
+
135
+ export const DecimalSteps: Story = {
136
+ args: { label: "Weight (kg)", modelValue: 0.2, min: 0, max: 2, step: 0.1 },
137
+ render,
138
+ parameters: {
139
+ docs: { description: { story: "A fractional `step`: stepping up from 0.2 gives 0.3, not 0.30000000000000004, and phones show a decimal keypad." } },
140
+ },
141
+ };
142
+
143
+ export const NegativeRange: Story = {
144
+ args: { label: "Temperature offset", modelValue: 0, min: -10, max: 10 },
145
+ render,
146
+ parameters: { docs: { description: { story: "A negative `min`: the input accepts a typed minus sign." } } },
147
+ };
148
+
149
+ export const WithError: Story = {
150
+ args: { fieldHasError: true, errorMessage: "Please choose between 0 and 10", modelValue: 12 },
151
+ render,
152
+ };
153
+
154
+ export const LocalisedLabels: Story = { args: { stepDownText: "Diminuer", stepUpText: "Augmenter" }, render };
155
+
156
+ export const Underlined: Story = { args: { inputVariant: "underlined" }, render };
157
+
158
+ export const StressTest: Story = {
159
+ name: "Stress Test (Worst-Case Data)",
160
+ args: {
161
+ modelValue: 123456789012,
162
+ min: -999999999999,
163
+ max: 999999999999999,
164
+ step: 0.0001,
165
+ label: "🔢 Gewünschteanzahlderteilnehmendenpersonen https://example.com/a/very/long/unbroken/url <b>not bold</b> عدد",
166
+ descriptionText: "يرجى إدخال رقم Geben Sie eine Zahl zwischen minus einer Billion und einer Billiarde ein.",
167
+ errorMessage: "DiesezahlliegtleideraußerhalbdeszulässigenBereichsbitteversuchenSieeserneut",
168
+ fieldHasError: true,
169
+ stepDownText: "Verringern 🔽",
170
+ stepUpText: "Erhöhen 🔼",
171
+ },
172
+ render,
173
+ parameters: {
174
+ initialCanvas: "mobileCanvas",
175
+ docs: {
176
+ description: {
177
+ story:
178
+ "A 12-digit value, a min of minus a trillion, a 15-digit max and a 0.0001 step, with long unbroken German/URL/emoji/RTL/HTML-like copy. Check at every canvas width: the copy wraps inside the dashed outline, the input's width is capped (12em) so it fits a phone, the step buttons keep their size, and markup renders as text. Clear the field and press step-up (starts from 0), type 9999999999999999 and press step-down (clamps to max), and set `step` to 0.",
179
+ },
180
+ },
181
+ },
182
+ };
@@ -0,0 +1,206 @@
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputTextField from "../InputTextField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { FormUiTheme, InputMode, InputTypesText, InputUiVariant } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+
8
+ interface StoryArgs {
9
+ modelValue: string;
10
+ name: string;
11
+ label: string;
12
+ type: InputTypesText;
13
+ inputmode: InputMode;
14
+ placeholder: string;
15
+ min: string;
16
+ max: string;
17
+ autocomplete: string;
18
+ errorMessage: string;
19
+ fieldHasError: boolean;
20
+ required: boolean;
21
+ readonly: boolean;
22
+ theme: FormUiTheme;
23
+ inputVariant: InputUiVariant;
24
+ styleClassPassthrough: string[];
25
+ descriptionText: string;
26
+ descriptionHtml: string;
27
+ }
28
+
29
+ const meta: Meta<StoryArgs> = {
30
+ title: "Components/Forms/Input Text/InputTextField",
31
+ component: InputTextField,
32
+ argTypes: {
33
+ modelValue: { control: "text", table: { category: "Model" } },
34
+ name: { control: "text", table: { category: "Basic" } },
35
+ label: { control: "text", table: { category: "Basic" } },
36
+ type: {
37
+ control: { type: "select" },
38
+ options: ["text", "email", "password", "number", "tel", "url", "date"],
39
+ table: { category: "Basic" },
40
+ },
41
+ inputmode: {
42
+ control: { type: "select" },
43
+ options: ["text", "email", "tel", "url", "search", "numeric", "none", "decimal"],
44
+ table: { category: "Basic" },
45
+ },
46
+ placeholder: { control: "text", table: { category: "Basic" } },
47
+ min: { control: "text", table: { category: "Basic" } },
48
+ max: { control: "text", table: { category: "Basic" } },
49
+ autocomplete: { control: "text", description: "Native autocomplete token, e.g. `email`", table: { category: "Basic" } },
50
+ errorMessage: { control: "text", table: { category: "Validation" } },
51
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
52
+ required: { control: "boolean", table: { category: "Validation" } },
53
+ readonly: { control: "boolean", table: { category: "States" } },
54
+ theme: {
55
+ control: { type: "select" },
56
+ options: ["default", "success", "error", "warning"],
57
+ table: { category: "Styling" },
58
+ },
59
+ inputVariant: {
60
+ control: { type: "select" },
61
+ options: ["normal", "outlined", "underlined"],
62
+ table: { category: "Styling" },
63
+ },
64
+ styleClassPassthrough: { control: "object", table: { category: "Styling" } },
65
+ descriptionText: { control: "text", description: "Story-only: fills the descriptionText slot", table: { category: "Slots" } },
66
+ descriptionHtml: { control: "text", description: "Story-only: fills the descriptionHtml slot", table: { category: "Slots" } },
67
+ },
68
+ args: {
69
+ modelValue: "",
70
+ name: "email",
71
+ label: "Email address",
72
+ type: "email",
73
+ inputmode: "email",
74
+ placeholder: "name@example.com",
75
+ min: "",
76
+ max: "",
77
+ autocomplete: "email",
78
+ errorMessage: "",
79
+ fieldHasError: false,
80
+ required: false,
81
+ readonly: false,
82
+ theme: "default",
83
+ inputVariant: "normal",
84
+ styleClassPassthrough: [],
85
+ descriptionText: "",
86
+ descriptionHtml: "",
87
+ },
88
+ decorators: [
89
+ (story, context) => ({
90
+ components: { story, CanvasSwitcher },
91
+ setup() {
92
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
93
+ return { canvasName };
94
+ },
95
+ template: `
96
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
97
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
98
+ </div>
99
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
100
+ <story />
101
+ </div>
102
+ `,
103
+ }),
104
+ ],
105
+ parameters: {
106
+ layout: "fullscreen",
107
+ docs: {
108
+ description: {
109
+ component:
110
+ "Labelled text field: `InputLabel`, an optional description (`descriptionText`/`descriptionHtml` slots, moved after the control for the `outlined` variant), `InputText` and an error strip, linked through `aria-describedby`. `left`/`right` slots pass through to `InputText`. `style-class-passthrough` lands on the `.input-text-field` root only.",
111
+ },
112
+ },
113
+ },
114
+ };
115
+
116
+ export default meta;
117
+ type Story = StoryObj<StoryArgs>;
118
+
119
+ const render = (args: StoryArgs) => ({
120
+ components: { InputTextField },
121
+ setup() {
122
+ const componentArgs = computed(() => {
123
+ const { modelValue: _modelValue, descriptionText: _text, descriptionHtml: _html, min, max, autocomplete, ...rest } = args;
124
+ return { ...rest, min: min || undefined, max: max || undefined, autocomplete: autocomplete || undefined };
125
+ });
126
+ return { args, componentArgs };
127
+ },
128
+ template: `
129
+ <InputTextField v-model="args.modelValue" v-bind="componentArgs">
130
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
131
+ <template v-if="args.descriptionHtml" #descriptionHtml><span v-html="args.descriptionHtml"></span></template>
132
+ </InputTextField>
133
+ <p style="margin-block-start: 1.6rem; font-family: monospace;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
134
+ `,
135
+ });
136
+
137
+ export const Default: Story = { render };
138
+
139
+ export const WithDescription: Story = { args: { descriptionText: "We only use it to reply to you." }, render };
140
+
141
+ export const WithHtmlDescription: Story = {
142
+ args: {
143
+ label: "Username",
144
+ type: "text",
145
+ inputmode: "text",
146
+ autocomplete: "username",
147
+ placeholder: "",
148
+ descriptionHtml: "Use <strong>3 to 20</strong> letters, numbers or <em>underscores</em>.",
149
+ },
150
+ render,
151
+ };
152
+
153
+ export const Required: Story = { args: { required: true }, render };
154
+
155
+ export const WithError: Story = {
156
+ args: { fieldHasError: true, errorMessage: "Please enter a valid email address", modelValue: "not-an-email" },
157
+ render,
158
+ };
159
+
160
+ export const Readonly: Story = { args: { readonly: true, modelValue: "alex.morgan@example.com" }, render };
161
+
162
+ export const Outlined: Story = {
163
+ args: { inputVariant: "outlined", descriptionText: "We only use it to reply to you." },
164
+ render,
165
+ parameters: { docs: { description: { story: "The `outlined` variant renders the description after the control and error." } } },
166
+ };
167
+
168
+ export const Underlined: Story = { args: { inputVariant: "underlined" }, render };
169
+
170
+ export const DateWithMinMax: Story = {
171
+ args: {
172
+ type: "date",
173
+ inputmode: "text",
174
+ label: "Appointment date",
175
+ name: "appointmentDate",
176
+ autocomplete: "",
177
+ placeholder: "",
178
+ min: new Date().toISOString().split("T")[0],
179
+ max: "2027-12-31",
180
+ },
181
+ render,
182
+ };
183
+
184
+ export const StressTest: Story = {
185
+ name: "Stress Test (Worst-Case Data)",
186
+ args: {
187
+ modelValue: "alex.morgan.with.a.very.long.unbroken.address@subdomain.northtown-example.co.uk",
188
+ label:
189
+ "📧 Elektronischepostadressefürdiebenachrichtigungüberbestellungen https://example.com/a/very/long/unbroken/url <b>not bold</b> البريد",
190
+ descriptionText: "يرجى إدخال بريدك الإلكتروني Wir verwenden Ihre Adresse ausschließlich für Rückfragen zu Ihrer Bestellung.",
191
+ placeholder: "Bitte geben Sie Ihre vollständige E-Mail-Adresse ein",
192
+ errorMessage: "DieseadresseistleiderungültigbitteüberprüfenSiedieSchreibweise",
193
+ fieldHasError: true,
194
+ required: true,
195
+ },
196
+ render,
197
+ parameters: {
198
+ initialCanvas: "mobileCanvas",
199
+ docs: {
200
+ description: {
201
+ story:
202
+ "Long unbroken German, URL, emoji, HTML-like and right-to-left copy in the label, description, value, placeholder and error. Check at every canvas width: the label, description and error wrap inside the dashed outline, the input never pushes past it, and markup renders as text.",
203
+ },
204
+ },
205
+ },
206
+ };
@@ -1,7 +1,7 @@
1
1
  // https://nuxt.com/docs/getting-started/testing#unit-testing
2
2
  import type { VueWrapper } from "@vue/test-utils";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
- import ComponentUnderTest from "../InputPasswordWithLabel.vue";
4
+ import ComponentUnderTest from "../InputPasswordField.vue";
5
5
 
6
6
  const initialPropsData = {
7
7
  name: "password",
@@ -11,21 +11,33 @@ const initialPropsData = {
11
11
  };
12
12
 
13
13
  let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
14
- const wrapperFactory = (propsData = {}) => {
14
+ const wrapperFactory = (propsData = {}, slots = {}) => {
15
15
  const mockPropsData = { ...initialPropsData, ...propsData };
16
16
 
17
17
  return mountSuspended(ComponentUnderTest, {
18
18
  attachTo: document.body,
19
19
  props: mockPropsData,
20
+ slots,
20
21
  });
21
22
  };
22
23
 
23
- describe("InputPasswordWithLabel", () => {
24
+ afterEach(() => {
25
+ wrapper?.unmount();
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ describe("InputPasswordField", () => {
24
30
  it("mounts without error", async () => {
25
31
  wrapper = await wrapperFactory();
26
32
  expect(wrapper.vm).toBeTruthy();
27
33
  });
28
34
 
35
+ it("renders an InputTextField root with the input-password-field class", async () => {
36
+ wrapper = await wrapperFactory();
37
+ expect(wrapper.classes()).toContain("input-text-field");
38
+ expect(wrapper.classes()).toContain("input-password-field");
39
+ });
40
+
29
41
  it("defaults to type=password and the default toggle button text", async () => {
30
42
  wrapper = await wrapperFactory();
31
43
 
@@ -33,6 +45,14 @@ describe("InputPasswordWithLabel", () => {
33
45
  expect(wrapper.find("button").text()).toContain("Show password");
34
46
  });
35
47
 
48
+ it("defaults autocomplete to current-password and forwards a custom one", async () => {
49
+ wrapper = await wrapperFactory();
50
+ expect(wrapper.find("input").attributes("autocomplete")).toBe("current-password");
51
+
52
+ await wrapper.setProps({ autocomplete: "new-password" });
53
+ expect(wrapper.find("input").attributes("autocomplete")).toBe("new-password");
54
+ });
55
+
36
56
  it("toggles the input type and button text when the toggle button is clicked", async () => {
37
57
  wrapper = await wrapperFactory();
38
58
 
@@ -42,6 +62,11 @@ describe("InputPasswordWithLabel", () => {
42
62
  expect(wrapper.find("button").text()).toContain("Hide password");
43
63
  });
44
64
 
65
+ it("ignores a passed type so the toggle stays in control", async () => {
66
+ wrapper = await wrapperFactory({ type: "text" });
67
+ expect(wrapper.find("input").attributes("type")).toBe("password");
68
+ });
69
+
45
70
  it("uses custom showPasswordText/hidePasswordText when provided", async () => {
46
71
  wrapper = await wrapperFactory({
47
72
  showPasswordText: "Afficher le mot de passe",
@@ -54,4 +79,18 @@ describe("InputPasswordWithLabel", () => {
54
79
 
55
80
  expect(wrapper.find("button").text()).toContain("Masquer le mot de passe");
56
81
  });
82
+
83
+ it("renders no description and no description id without a description slot", async () => {
84
+ wrapper = await wrapperFactory();
85
+
86
+ expect(wrapper.find(".input-text-description").exists()).toBe(false);
87
+ expect(wrapper.find("input").attributes("aria-describedby")).toBeUndefined();
88
+ });
89
+
90
+ it("forwards the descriptionText slot", async () => {
91
+ wrapper = await wrapperFactory({}, { descriptionText: () => "At least 12 characters" });
92
+
93
+ expect(wrapper.text()).toContain("At least 12 characters");
94
+ expect(wrapper.find("input").attributes("aria-describedby")).toMatch(/-description$/);
95
+ });
57
96
  });
@@ -0,0 +1,166 @@
1
+ // https://nuxt.com/docs/getting-started/testing#unit-testing
2
+ import { nextTick } from "vue";
3
+ import type { VueWrapper } from "@vue/test-utils";
4
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
5
+ import ComponentUnderTest from "../InputText.vue";
6
+
7
+ const initialPropsData = {
8
+ id: "test-input",
9
+ name: "testName",
10
+ modelValue: "",
11
+ };
12
+
13
+ let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
14
+ const wrapperFactory = (propsData = {}, slots = {}) => {
15
+ const mockPropsData = { ...initialPropsData, ...propsData };
16
+
17
+ return mountSuspended(ComponentUnderTest, {
18
+ attachTo: document.body,
19
+ props: mockPropsData,
20
+ slots,
21
+ });
22
+ };
23
+
24
+ const beforeInput = (data: string | null) => new InputEvent("beforeinput", { data, cancelable: true, inputType: "insertText" });
25
+
26
+ afterEach(() => {
27
+ wrapper?.unmount();
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ describe("InputText", () => {
32
+ it("mounts without error", async () => {
33
+ wrapper = await wrapperFactory();
34
+ expect(wrapper.vm).toBeTruthy();
35
+ });
36
+
37
+ it("renders the native input with the .input-text class", async () => {
38
+ wrapper = await wrapperFactory();
39
+ expect(wrapper.find("input.input-text").exists()).toBe(true);
40
+ });
41
+
42
+ it("binds v-model to the input value", async () => {
43
+ wrapper = await wrapperFactory({ modelValue: "hello" });
44
+ expect((wrapper.find("input").element as HTMLInputElement).value).toBe("hello");
45
+
46
+ await wrapper.find("input").setValue("changed");
47
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["changed"]);
48
+ });
49
+
50
+ it("does not set min/max attributes when not provided", async () => {
51
+ wrapper = await wrapperFactory();
52
+ const input = wrapper.find("input");
53
+
54
+ expect(input.attributes("min")).toBeUndefined();
55
+ expect(input.attributes("max")).toBeUndefined();
56
+ });
57
+
58
+ it("passes through a string min/max (e.g. for type=date)", async () => {
59
+ wrapper = await wrapperFactory({ type: "date", min: "2026-01-01", max: "2026-12-31" });
60
+ const input = wrapper.find("input");
61
+
62
+ expect(input.attributes("type")).toBe("date");
63
+ expect(input.attributes("min")).toBe("2026-01-01");
64
+ expect(input.attributes("max")).toBe("2026-12-31");
65
+ });
66
+
67
+ it("passes through a numeric min/max (e.g. for type=number)", async () => {
68
+ wrapper = await wrapperFactory({ type: "number", min: 0, max: 100 });
69
+ const input = wrapper.find("input");
70
+
71
+ expect(input.attributes("min")).toBe("0");
72
+ expect(input.attributes("max")).toBe("100");
73
+ });
74
+
75
+ it("forwards readonly and autocomplete to the input", async () => {
76
+ wrapper = await wrapperFactory({ readonly: true, autocomplete: "email" });
77
+ const input = wrapper.find("input");
78
+
79
+ expect(input.attributes("readonly")).toBeDefined();
80
+ expect(input.attributes("autocomplete")).toBe("email");
81
+ expect(wrapper.find(".input-text-wrapper").attributes("data-readonly")).toBe("");
82
+ });
83
+
84
+ it("omits readonly, autocomplete and aria-describedby by default", async () => {
85
+ wrapper = await wrapperFactory();
86
+ const input = wrapper.find("input");
87
+
88
+ expect(input.attributes("readonly")).toBeUndefined();
89
+ expect(input.attributes("autocomplete")).toBeUndefined();
90
+ expect(input.attributes("aria-describedby")).toBeUndefined();
91
+ });
92
+
93
+ it("sets data-input-variant, data-theme and data-invalid on the wrapper", async () => {
94
+ wrapper = await wrapperFactory({ inputVariant: "underlined", theme: "warning", fieldHasError: true });
95
+ const root = wrapper.find(".input-text-wrapper");
96
+
97
+ expect(root.attributes("data-input-variant")).toBe("underlined");
98
+ expect(root.attributes("data-theme")).toBe("warning");
99
+ expect(root.attributes("data-invalid")).toBe("");
100
+ expect(wrapper.find("input").attributes("aria-invalid")).toBe("true");
101
+ });
102
+
103
+ it("renders slot holders and data-has-*-slot only for filled slots", async () => {
104
+ wrapper = await wrapperFactory({}, { left: () => "L" });
105
+ const root = wrapper.find(".input-text-wrapper");
106
+
107
+ expect(wrapper.find(".input-text-slot-left").text()).toBe("L");
108
+ expect(wrapper.find(".input-text-slot-right").exists()).toBe(false);
109
+ expect(root.attributes("data-has-left-slot")).toBe("");
110
+ expect(root.attributes("data-has-right-slot")).toBeUndefined();
111
+ });
112
+
113
+ it("sets isActive on focus and blur, reflected as data-active", async () => {
114
+ wrapper = await wrapperFactory({ isActive: false });
115
+ const input = wrapper.find("input");
116
+
117
+ await input.trigger("focusin");
118
+ expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([true]);
119
+
120
+ await input.trigger("focusout");
121
+ expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([false]);
122
+
123
+ await wrapper.setProps({ isActive: true, isDirty: true });
124
+ const root = wrapper.find(".input-text-wrapper");
125
+ expect(root.attributes("data-active")).toBe("");
126
+ expect(root.attributes("data-dirty")).toBe("");
127
+ });
128
+
129
+ it("blocks non-digit typing for inputmode numeric", async () => {
130
+ wrapper = await wrapperFactory({ inputmode: "numeric" });
131
+ const input = wrapper.find("input").element;
132
+
133
+ const letter = beforeInput("a");
134
+ input.dispatchEvent(letter);
135
+ expect(letter.defaultPrevented).toBe(true);
136
+
137
+ const digit = beforeInput("7");
138
+ input.dispatchEvent(digit);
139
+ expect(digit.defaultPrevented).toBe(false);
140
+
141
+ const deletion = beforeInput(null);
142
+ input.dispatchEvent(deletion);
143
+ expect(deletion.defaultPrevented).toBe(false);
144
+
145
+ expect(wrapper.find("input").attributes("pattern")).toBe("[0-9]+");
146
+ });
147
+
148
+ it("does not filter typing for other inputmodes", async () => {
149
+ wrapper = await wrapperFactory({ inputmode: "text" });
150
+ const letter = beforeInput("a");
151
+ wrapper.find("input").element.dispatchEvent(letter);
152
+
153
+ expect(letter.defaultPrevented).toBe(false);
154
+ expect(wrapper.find("input").attributes("pattern")).toBeUndefined();
155
+ });
156
+
157
+ it("applies styleClassPassthrough to the input and reacts to changes", async () => {
158
+ wrapper = await wrapperFactory({ styleClassPassthrough: ["first-class"] });
159
+ expect(wrapper.find("input").classes()).toContain("first-class");
160
+
161
+ await wrapper.setProps({ styleClassPassthrough: ["second-class"] });
162
+ await nextTick();
163
+ expect(wrapper.find("input").classes()).toContain("second-class");
164
+ expect(wrapper.find("input").classes()).not.toContain("first-class");
165
+ });
166
+ });