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,9 +1,11 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
1
  import { computed, ref } from "vue";
3
- import StorybookComponent from "../MultipleRadiobuttons.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import MultipleRadiobuttons from "../MultipleRadiobuttons.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
5
  import type { IFormMultipleOptions, InputUiVariant, OptionsLayout } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
- interface MultipleRadiobuttonsStoryArgs {
8
+ interface StoryArgs {
7
9
  name: string;
8
10
  legend: string;
9
11
  errorMessage: string;
@@ -16,17 +18,25 @@ interface MultipleRadiobuttonsStoryArgs {
16
18
  inputVariant: InputUiVariant;
17
19
  gap: string;
18
20
  descriptionText: string;
21
+ options: IFormMultipleOptions["data"];
19
22
  }
20
23
 
21
- export default {
24
+ const enquiryOptions: IFormMultipleOptions["data"] = [
25
+ { id: "1", name: "enquiryType", value: "booking", label: "Booking" },
26
+ { id: "2", name: "enquiryType", value: "consultation", label: "Consultation" },
27
+ { id: "3", name: "enquiryType", value: "product", label: "Product advice" },
28
+ { id: "4", name: "enquiryType", value: "other", label: "Something else" },
29
+ ];
30
+
31
+ const meta: Meta<StoryArgs> = {
22
32
  title: "Components/Forms/Input Radio/MultipleRadiobuttons",
23
- component: StorybookComponent,
33
+ component: MultipleRadiobuttons,
24
34
  argTypes: {
25
35
  name: { control: "text", description: "Shared name of every radio in the group", table: { category: "Basic" } },
26
36
  legend: { control: "text", table: { category: "Basic" } },
27
37
  errorMessage: { control: "text", table: { category: "Basic" } },
28
38
  required: { control: "boolean", table: { category: "Basic" } },
29
- descriptionText: { control: "text", description: "descriptionText slot", table: { category: "Slots" } },
39
+ descriptionText: { control: "text", description: "Story-only: fills the descriptionText slot", table: { category: "Slots" } },
30
40
  fieldHasError: { control: "boolean", table: { category: "States" } },
31
41
  isButton: { control: "boolean", table: { category: "Styling" } },
32
42
  isPill: { control: "boolean", description: "Only with isButton", table: { category: "Styling" } },
@@ -49,8 +59,14 @@ export default {
49
59
  },
50
60
  gap: {
51
61
  control: "text",
52
- description: "Sets --multiple-radiobuttons-gap on the story wrapper",
53
- table: { category: "Tokens" },
62
+ description:
63
+ "**Story control, not a prop.** Sets `--multiple-radiobuttons-gap` on the story wrapper. In your CSS: `.enquiry-form { --multiple-radiobuttons-gap: 0.6rem; }`",
64
+ table: { category: "CSS tokens (story only, set in your CSS)" },
65
+ },
66
+ options: {
67
+ control: "object",
68
+ description: "Story-only: the `data` array of the `fieldData` model",
69
+ table: { category: "Model" },
54
70
  },
55
71
  },
56
72
  args: {
@@ -66,51 +82,135 @@ export default {
66
82
  inputVariant: "normal",
67
83
  gap: "",
68
84
  descriptionText: "Pick the closest match.",
85
+ options: enquiryOptions,
86
+ },
87
+ decorators: [
88
+ (story, context) => ({
89
+ components: { story, CanvasSwitcher },
90
+ setup() {
91
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
92
+ return { canvasName };
93
+ },
94
+ template: `
95
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
96
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
97
+ </div>
98
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
99
+ <story />
100
+ </div>
101
+ `,
102
+ }),
103
+ ],
104
+ parameters: {
105
+ layout: "fullscreen",
106
+ docs: {
107
+ description: {
108
+ component:
109
+ "A `radiogroup` fieldset of radios built from the `fieldData` model, bound to a single-value `v-model`. `isButton` swaps the labelled radios for button tiles (`isPill` rounds them), `optionsLayout` lays them out as equal-width columns, an inline wrap or a block column, and `errorMessage` with `fieldHasError` shows the error strip. `required` puts native `required` on every radio, which one checked radio satisfies. Slots: `descriptionText`, `descriptionHtml`, `checkedIcon`, `itemIcon`.",
110
+ },
111
+ },
69
112
  },
70
- } as Meta<MultipleRadiobuttonsStoryArgs>;
113
+ };
71
114
 
72
- const Template: StoryFn<MultipleRadiobuttonsStoryArgs> = (args) => ({
73
- components: { StorybookComponent },
115
+ export default meta;
116
+ type Story = StoryObj<StoryArgs>;
117
+
118
+ const render = (args: StoryArgs) => ({
119
+ components: { MultipleRadiobuttons },
74
120
  setup() {
75
- const modelValue = ref<string>("");
76
- const fieldData = ref<IFormMultipleOptions>({
77
- data: [
78
- { id: "1", name: "enquiryType", value: "booking", label: "Booking" },
79
- { id: "2", name: "enquiryType", value: "consultation", label: "Consultation" },
80
- { id: "3", name: "enquiryType", value: "product", label: "Product advice" },
81
- { id: "4", name: "enquiryType", value: "other", label: "Something else" },
82
- ],
83
- total: 4,
84
- skip: 0,
85
- limit: 10,
86
- });
87
121
  const componentArgs = computed(() => {
88
- const { gap: _gap, descriptionText: _description, ...rest } = args;
122
+ const { gap: _gap, descriptionText: _descriptionText, options: _options, ...rest } = args;
89
123
  return rest;
90
124
  });
125
+ const fieldData = computed<IFormMultipleOptions>(() => ({
126
+ data: args.options,
127
+ total: args.options.length,
128
+ skip: 0,
129
+ limit: args.options.length,
130
+ }));
91
131
  const tokenStyles = computed(() => (args.gap ? { "--multiple-radiobuttons-gap": args.gap } : {}));
92
- return { args, componentArgs, tokenStyles, modelValue, fieldData };
132
+ const modelValue = ref<string>("");
133
+ return { args, componentArgs, fieldData, tokenStyles, modelValue };
93
134
  },
94
135
  template: `
95
- <div style="margin: 36px; max-width: 560px;" :style="tokenStyles">
96
- <StorybookComponent v-bind="componentArgs" v-model="modelValue" v-model:field-data="fieldData">
136
+ <div :style="tokenStyles">
137
+ <MultipleRadiobuttons v-bind="componentArgs" v-model="modelValue" :field-data="fieldData">
97
138
  <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
98
- </StorybookComponent>
99
- <p style="margin-top: 2rem; font-family: monospace;">modelValue: {{ JSON.stringify(modelValue) }}</p>
139
+ </MultipleRadiobuttons>
140
+ <p style="margin-block-start: 2rem; font-family: monospace; overflow-wrap: anywhere;">modelValue: {{ JSON.stringify(modelValue) }}</p>
100
141
  </div>
101
142
  `,
102
143
  });
103
144
 
104
- export const Default = Template.bind({});
145
+ export const Default: Story = { render };
105
146
 
106
- export const Buttons = Template.bind({});
107
- Buttons.args = { isButton: true, optionsLayout: "inline" };
147
+ export const Buttons: Story = {
148
+ args: { isButton: true, optionsLayout: "inline" },
149
+ render,
150
+ parameters: { docs: { description: { story: "Button tiles in an inline wrap, each with the `itemIcon` decorator." } } },
151
+ };
108
152
 
109
- export const Pills = Template.bind({});
110
- Pills.args = { isButton: true, isPill: true, optionsLayout: "inline" };
153
+ export const Pills: Story = {
154
+ args: { isButton: true, isPill: true, optionsLayout: "inline" },
155
+ render,
156
+ parameters: { docs: { description: { story: "Button tiles with `isPill` for fully rounded ends." } } },
157
+ };
111
158
 
112
- export const Block = Template.bind({});
113
- Block.args = { optionsLayout: "block" };
159
+ export const Block: Story = {
160
+ args: { optionsLayout: "block" },
161
+ render,
162
+ parameters: { docs: { description: { story: "`optionsLayout=\"block\"` stacks the options in one column." } } },
163
+ };
164
+
165
+ export const ErrorState: Story = {
166
+ args: { fieldHasError: true },
167
+ render,
168
+ parameters: {
169
+ docs: { description: { story: "`fieldHasError` shows the error message and links it to every radio with `aria-describedby`." } },
170
+ },
171
+ };
114
172
 
115
- export const ErrorState = Template.bind({});
116
- ErrorState.args = { fieldHasError: true };
173
+ export const StressTest: Story = {
174
+ name: "Stress Test (Worst-Case Data)",
175
+ args: {
176
+ name: "anfrage art",
177
+ legend:
178
+ "📞 Worumgehtesbeiihreranfragebittewählensiediepassendstekategorieaus https://example.com/a/very/long/unbroken/url/that/should/wrap/somewhere <script>alert(1)</script>",
179
+ descriptionText:
180
+ "نوع الاستفسار Wählen Sie die Kategorie, die am besten zu Ihrer Anfrage passt, damit wir Sie an die richtige Person weiterleiten können. <b>not bold</b>",
181
+ errorMessage: "BittewählenSieeineAnfragekategorieausbevorSiedasFormularabsenden",
182
+ fieldHasError: true,
183
+ options: [
184
+ { id: "1", name: "s", value: "a", label: "A" },
185
+ { id: "2", name: "s", value: "", label: "" },
186
+ { id: "3", name: "s", value: "long", label: "Terminvereinbarungsänderungsanfragebearbeitung" },
187
+ { id: "4", name: "s", value: "url", label: "https://example.com/a/very/long/unbroken/url/that/should/wrap/somewhere" },
188
+ { id: "5", name: "s", value: "has spaces <b>", label: "<b>Not bold</b> & <i>not italic</i>" },
189
+ { id: "6", name: "s", value: "emoji", label: "💬 Something else 🤔" },
190
+ { id: "7", name: "s", value: "rtl", label: "استشارة" },
191
+ {
192
+ id: "8",
193
+ name: "s",
194
+ value: "sentence",
195
+ label: "A question about an existing booking, including changing the time, the stylist or the treatment",
196
+ },
197
+ ...Array.from({ length: 22 }, (_, index) => ({
198
+ id: `extra-${index}`,
199
+ name: "s",
200
+ value: `extra-${index}`,
201
+ label: `Option ${index + 9}`,
202
+ })),
203
+ ],
204
+ },
205
+ render,
206
+ parameters: {
207
+ initialCanvas: "mobileCanvas",
208
+ docs: {
209
+ story: { inline: false, height: "900px" },
210
+ description: {
211
+ story:
212
+ "30 options including an empty label, a single character, long unbroken German and URL labels, emoji, right-to-left text, HTML-like text and a value with spaces. Check at every canvas width: the legend, description, labels and error wrap inside the dashed outline, markup renders as text, the equal-width columns never get wider than the container, and clicking any label (including the odd ones) selects its own radio. Switch `optionsLayout` to `inline` and `block`, and turn on `isButton` and `isPill`: long button labels wrap inside their tile.",
213
+ },
214
+ },
215
+ },
216
+ };
@@ -1,4 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { defineComponent } from "vue";
2
+ import { afterEach, describe, it, expect, vi } from "vitest";
3
+ import type { VueWrapper } from "@vue/test-utils";
2
4
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
5
  import type { OptionsLayout, IFormMultipleOptions } from "~/types/forms/types.forms";
4
6
  import MultipleRadiobuttons from "../MultipleRadiobuttons.vue";
@@ -12,15 +14,60 @@ const defaultProps = {
12
14
  fieldData: tagsData as IFormMultipleOptions,
13
15
  };
14
16
 
15
- const createWrapper = (props = {}, slots = {}) =>
16
- mountSuspended(MultipleRadiobuttons, {
17
+ const mounted: VueWrapper[] = [];
18
+
19
+ const createWrapper = async (props = {}, slots = {}) => {
20
+ const wrapper = await mountSuspended(MultipleRadiobuttons, {
17
21
  attachTo: document.body,
18
22
  props: { ...defaultProps, ...props },
19
23
  slots,
20
24
  });
25
+ mounted.push(wrapper);
26
+ return wrapper;
27
+ };
28
+
29
+ afterEach(() => {
30
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
31
+ vi.restoreAllMocks();
32
+ });
21
33
 
22
34
  describe("MultipleRadiobuttons", () => {
23
35
  describe("rendering", () => {
36
+ it("gives button radios unique, space-free ids that don't come from option values", async () => {
37
+ const wrapper = await createWrapper({
38
+ isButton: true,
39
+ fieldData: { ...tagsData, data: [{ id: "x", name: "tags", value: "has spaces", label: "Spaces" }, ...tagsData.data] },
40
+ });
41
+ const ids = wrapper.findAll('input[type="radio"]').map((radio) => radio.attributes("id")!);
42
+
43
+ expect(new Set(ids).size).toBe(ids.length);
44
+ ids.forEach((id) => expect(id).not.toMatch(/\s/));
45
+ expect(ids).not.toContain("tags-has spaces");
46
+ });
47
+
48
+ it("keeps button radio ids unique across two groups sharing a name", async () => {
49
+ const Host = defineComponent({
50
+ components: { MultipleRadiobuttons },
51
+ setup: () => ({ props: { ...defaultProps, isButton: true } }),
52
+ template: `<div><MultipleRadiobuttons v-bind="props" /><MultipleRadiobuttons v-bind="props" /></div>`,
53
+ });
54
+ const host = await mountSuspended(Host);
55
+ mounted.push(host);
56
+ const ids = host.findAll('input[type="radio"]').map((radio) => radio.attributes("id"));
57
+
58
+ expect(ids).toHaveLength(tagsData.data.length * 2);
59
+ expect(new Set(ids).size).toBe(ids.length);
60
+ });
61
+
62
+ it("updates passthrough classes when the prop changes", async () => {
63
+ const wrapper = await createWrapper({ styleClassPassthrough: ["first"] });
64
+ await wrapper.setProps({ styleClassPassthrough: ["second"] });
65
+ const classes = wrapper.find(".form-fieldset").classes();
66
+
67
+ expect(classes).toContain("second");
68
+ expect(classes).not.toContain("first");
69
+ });
70
+
24
71
  it("renders a radiogroup fieldset with the legend", async () => {
25
72
  const wrapper = await createWrapper();
26
73
  const fieldset = wrapper.find("fieldset");
@@ -1,36 +1,71 @@
1
- # InputRangeCore — Consumer Styling Guide
1
+ # InputRange / InputRangeField — Consumer Styling Guide
2
+
3
+ > **Renamed 2026-10-16**: `InputRangeCore` → `InputRange`, `InputRangeDefault` → `InputRangeField`.
4
+ > Classes changed with them, see **State hooks**. Token names are unchanged.
2
5
 
3
6
  ## Public token API
4
7
 
5
8
  | Token | Default | Controls |
6
9
  |---|---|---|
7
10
  | `--input-range-accent-colour` | `var(--theme-accent)` | Native `accent-color` of the range input (track/thumb tint) |
8
- | `--input-range-marker-background-colour` | `hsl(0, 29%, 3%)` | Background colour of each `markers` slot dot |
9
- | `--input-range-marker-padding` | `0.5rem` | Padding of each marker dot |
11
+ | `--input-range-marker-background-colour` | `var(--theme-input-surface)` | Background colour of each `markers` slot dot |
12
+ | `--input-range-marker-padding` | `0.5rem` | Space around the icon in each marker dot (dot size = icon size + 2 × this) |
10
13
  | `--input-range-marker-border-radius` | `50%` | Corner radius of each marker dot |
11
14
  | `--input-range-marker-outline-width` | `0.1rem` | Marker dot outline width |
12
- | `--input-range-marker-outline-colour` | `var(--slate-04)` | Marker dot outline colour |
13
- | `--input-range-marker-icon-size` | `2rem` | Font size of the marker icon (`.marker-icon`) |
15
+ | `--input-range-marker-outline-colour` | `var(--theme-border)` | Marker dot outline colour |
16
+ | `--input-range-marker-icon-size` | `2rem` | Font size of the marker icon (`.input-range-marker-icon`) |
14
17
  | `--input-range-marker-icon-colour` | `var(--input-range-accent-colour, var(--theme-accent))` | Marker icon colour |
18
+ | `--input-range-marker-track-colour` | `var(--theme-border)` | The line joining the markers, drawn behind them |
19
+ | `--input-range-marker-track-width` | `0.2rem` | Thickness of that line |
20
+ | `--input-range-marker-selected-colour` | `var(--input-range-accent-colour, var(--theme-accent))` | Ring drawn around the selected marker (the restyled range thumb) |
21
+ | `--input-range-marker-selected-width` | `0.3rem` | Thickness of that ring |
15
22
  | `--input-range-datalist-font-size` | `1.4rem` | Font size of the `datalist` slot's option labels |
16
23
  | `--input-range-datalist-font-weight` | `500` | Font weight of the `datalist` slot's option labels |
17
24
 
18
- Geometry (thumb/track height, border radius) comes from the shared global form-geometry tokens
19
- (`--input-range-button-size`, `--form-input-border-radius`), not from this component's own
20
- tokens — see `theming-form-geometry-tokens.md`.
25
+ Geometry (thumb/track height, step-button size, border radius) comes from the shared global
26
+ form-geometry tokens (`--input-range-button-size`, `--form-input-border-radius`), not from this
27
+ component's own tokens: see `theming-form-geometry-tokens.md`.
28
+
29
+ Private (not public API): `--_input-range-marker-size`, the dot size worked out from the icon size
30
+ and padding, shared by the markers, the selected ring and the line inset.
21
31
 
22
- ```css
23
- .my-page {
24
- --input-range-accent-colour: var(--brand-primary);
25
- --input-range-marker-background-colour: var(--brand-primary);
26
- }
27
- ```
32
+ > **Changed 2026-10-16**: the marker dot defaults assumed a dark page (`hsl(0, 29%, 3%)`
33
+ > background, `--slate-04` outline). They now default to `--theme-input-surface` and
34
+ > `--theme-border`. Set the two marker tokens if you relied on the dark dots.
35
+ >
36
+ > **Changed 2026-10-16**: markers mode was broken: the range line was drawn across the icons and
37
+ > nothing showed the selected value (the thumb was hidden), and only the thin line was clickable.
38
+ > The line now sits behind the markers, the whole marker row is the slider, and the thumb is a ring
39
+ > the size of a marker that circles the selected one. Markers get an explicit size
40
+ > (`--input-range-marker-icon-size` + 2 × `--input-range-marker-padding`) so the ring lines up.
41
+
42
+ ---
43
+
44
+ ## State hooks
45
+
46
+ | Attribute | On | When |
47
+ |---|---|---|
48
+ | `data-theme` | `.input-range-wrapper`, `.input-range-field` | Always, mirrors `theme` |
49
+ | `data-invalid` | `.input-range-wrapper`, `.input-range-field` | `field-has-error` (the input also gets `aria-invalid="true"`) |
50
+ | `data-has-markers` | `.input-range-wrapper` | The `markers` slot is filled: the line moves behind the markers, the input becomes a transparent layer over the whole row, and the thumb becomes a ring around the selected marker |
28
51
 
29
- Or scope to a single instance via `styleClassPassthrough`:
52
+ Inner elements: `.input-range` (the native input), `.input-range-container`, `.input-range-slot`
53
+ (both step-button holders), `.input-range-slot-left`, `.input-range-slot-right`,
54
+ `.input-range-label` (the Field's `InputLabel`). Pre-styled classes for your `markers` slot
55
+ markup: `.input-range-markers` (the row), `.input-range-marker` (each dot),
56
+ `.input-range-marker-icon`; and `.input-range-datalist` for a slotted `<datalist>`.
30
57
 
31
- ```vue
32
- <InputRangeCore style-class-passthrough="price-range" ... />
33
- ```
58
+ > **Changed 2026-10-16**:
59
+ >
60
+ > - Classes renamed: `.input-range-core` → `.input-range`, `.input-range-with-label` →
61
+ > `.input-range-field`, `.slot.left` / `.slot.right` → `.input-range-slot-left` /
62
+ > `.input-range-slot-right`, markers-slot `.marker` / `.marker-icon` → `.input-range-marker` /
63
+ > `.input-range-marker-icon` (update your slot markup).
64
+ > - `.has-markers` on the input → `[data-has-markers]` on the wrapper; the Field's `.error` class →
65
+ > `[data-invalid]`. The Field's label class was the number field's `input-number-label`; it's
66
+ > now `input-range-label`.
67
+ > - Long label, description and error copy wraps (`overflow-wrap: anywhere` on
68
+ > `.input-range-field`), and the slider shrinks between its step buttons instead of overflowing.
34
69
 
35
70
  ---
36
71
 
@@ -44,15 +79,22 @@ Set the tokens above on an element you own (a page or section class, or a class
44
79
 
45
80
  ---
46
81
 
82
+ ## Class passthrough
83
+
84
+ `InputRange`'s `:style-class-passthrough` lands on the native `<input class="input-range">`.
85
+ `InputRangeField`'s lands on its root `.input-range-field` only. Both react to prop changes.
86
+
87
+ > **Changed 2026-10-16**: `InputRangeField` also forwarded its passthrough classes to the inner
88
+ > input, so a class meant for the field matched two elements. If you styled the input through the
89
+ > Field's passthrough, select `.your-class .input-range` instead.
90
+
91
+ ---
92
+
47
93
  ## Notes
48
94
 
49
95
  - `accent-color` was previously read from `--theme-form-range-accent-color`, a custom property
50
- never declared anywhere in the codebase (no fallback either) — per CSS spec this made the whole
51
- `accent-color` declaration invalid at computed-value time, so it silently fell back to the
52
- browser's native default accent colour instead of the intended theme colour. Fixed 2026-09-21;
53
- see the component's skill doc for detail.
96
+ never declared anywhere in the codebase (no fallback either). Per the CSS spec that made the
97
+ whole `accent-color` declaration invalid at computed-value time, so it silently fell back to the
98
+ browser's native default accent colour. Fixed 2026-09-21; see the skill doc for detail.
54
99
  - The `weight` prop (`FormWeight`) generates an `input-range--{weight}` class on the input, but no
55
- CSS in this component (or elsewhere in the library) currently styles that class — it's a no-op
56
- today. Left as-is rather than redesigned, since the original intent wasn't clear; flagged here
57
- so you don't spend time debugging "why doesn't `weight` do anything."
58
-
100
+ CSS in this component (or elsewhere in the library) styles that class: it's a no-op today.
@@ -0,0 +1,247 @@
1
+ <template>
2
+ <div
3
+ class="input-range-wrapper"
4
+ :data-theme="theme"
5
+ :data-invalid="fieldHasError ? '' : undefined"
6
+ :data-has-markers="slots.markers ? '' : undefined"
7
+ >
8
+ <div v-if="slots.left" class="input-range-slot input-range-slot-left">
9
+ <slot name="left"></slot>
10
+ </div>
11
+
12
+ <div class="input-range-container">
13
+ <slot v-if="slots.markers" name="markers"></slot>
14
+
15
+ <input
16
+ :id
17
+ v-model="modelValue"
18
+ type="range"
19
+ :name
20
+ :required
21
+ :min
22
+ :max
23
+ :step
24
+ :list="slots.datalist ? datalistId : undefined"
25
+ :class="['input-range', `input-range--${weight}`, elementClasses]"
26
+ :aria-describedby
27
+ :aria-invalid="fieldHasError ? 'true' : undefined"
28
+ />
29
+
30
+ <slot v-if="slots.datalist" name="datalist" :datalist-id="datalistId"></slot>
31
+ </div>
32
+ <div v-if="slots.right" class="input-range-slot input-range-slot-right">
33
+ <slot name="right"></slot>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import type { FormUiTheme, FormWeight } from "~/types/forms/types.forms";
40
+
41
+ interface Props {
42
+ id: string;
43
+ name: string;
44
+ min: number;
45
+ max: number;
46
+ step?: number;
47
+ placeholder?: string;
48
+ required?: boolean;
49
+ theme?: FormUiTheme;
50
+ weight?: FormWeight;
51
+ fieldHasError?: boolean;
52
+ styleClassPassthrough?: string | string[];
53
+ ariaDescribedby?: string;
54
+ }
55
+
56
+ const props = withDefaults(defineProps<Props>(), {
57
+ step: 1,
58
+ placeholder: "",
59
+ required: false,
60
+ theme: "default",
61
+ weight: "normal",
62
+ fieldHasError: false,
63
+ styleClassPassthrough: () => [],
64
+ ariaDescribedby: "",
65
+ });
66
+
67
+ const slots = useSlots();
68
+
69
+ const modelValue = defineModel<number>({
70
+ required: true,
71
+ });
72
+
73
+ const datalistId = useId();
74
+
75
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
76
+
77
+ watch(
78
+ () => props.styleClassPassthrough,
79
+ () => {
80
+ resetElementClasses(props.styleClassPassthrough);
81
+ }
82
+ );
83
+ </script>
84
+
85
+ <style lang="css">
86
+ @layer components {
87
+ .input-range-wrapper {
88
+ --_input-range-marker-size: calc(
89
+ var(--input-range-marker-icon-size, 2rem) + 2 * var(--input-range-marker-padding, 0.5rem)
90
+ );
91
+
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 1rem;
96
+
97
+ .input-range-slot {
98
+ align-self: flex-start;
99
+ flex-shrink: 0;
100
+
101
+ .input-button {
102
+ border-radius: var(--form-input-border-radius);
103
+ width: var(--input-range-button-size);
104
+ height: var(--input-range-button-size);
105
+
106
+ .btn-icon {
107
+ margin: initial;
108
+ }
109
+ }
110
+ }
111
+
112
+ .input-range-container {
113
+ flex-grow: 1;
114
+ min-inline-size: 0;
115
+
116
+ display: grid;
117
+ grid-template-areas: "element-stack";
118
+
119
+ .input-range-markers {
120
+ grid-area: element-stack;
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: space-between;
124
+ width: 100%;
125
+
126
+ .input-range-marker {
127
+ box-sizing: border-box;
128
+ display: grid;
129
+ place-items: center;
130
+ flex-shrink: 0;
131
+ inline-size: var(--_input-range-marker-size);
132
+ block-size: var(--_input-range-marker-size);
133
+ background-color: var(--input-range-marker-background-colour, var(--theme-input-surface));
134
+ border-radius: var(--input-range-marker-border-radius, 50%);
135
+ overflow: hidden;
136
+ outline: var(--input-range-marker-outline-width, 0.1rem) solid
137
+ var(--input-range-marker-outline-colour, var(--theme-border));
138
+
139
+ .input-range-marker-icon {
140
+ font-size: var(--input-range-marker-icon-size, 2rem);
141
+ line-height: 1;
142
+ display: block;
143
+ color: var(--input-range-marker-icon-colour, var(--input-range-accent-colour, var(--theme-accent)));
144
+ }
145
+ }
146
+ }
147
+
148
+ .input-range {
149
+ grid-area: element-stack;
150
+
151
+ accent-color: var(--input-range-accent-colour, var(--theme-accent));
152
+ height: var(--input-range-button-size);
153
+ margin: 0;
154
+ width: 100%;
155
+
156
+ &:focus-visible {
157
+ box-shadow: var(--form-focus-box-shadow);
158
+ }
159
+ }
160
+
161
+ .input-range-datalist {
162
+ display: flex;
163
+ flex-direction: column;
164
+ font-family: var(--font-family);
165
+ font-size: var(--input-range-datalist-font-size, 1.4rem);
166
+ font-weight: var(--input-range-datalist-font-weight, 500);
167
+ justify-content: space-between;
168
+ writing-mode: vertical-lr;
169
+ width: 100%;
170
+
171
+ option {
172
+ padding: 0;
173
+ }
174
+ }
175
+ }
176
+
177
+ &[data-has-markers] {
178
+ .input-range-container::before {
179
+ content: "";
180
+ grid-area: element-stack;
181
+ align-self: center;
182
+ z-index: 1;
183
+ height: var(--input-range-marker-track-width, 0.2rem);
184
+ margin-inline: calc(var(--_input-range-marker-size) / 2);
185
+ background-color: var(--input-range-marker-track-colour, var(--theme-border));
186
+ }
187
+
188
+ .input-range-markers {
189
+ z-index: 2;
190
+ }
191
+
192
+ .input-range {
193
+ -webkit-appearance: none;
194
+ appearance: none;
195
+ align-self: center;
196
+ z-index: 3;
197
+ height: var(--_input-range-marker-size);
198
+ background: transparent;
199
+ cursor: pointer;
200
+
201
+ &:focus-visible {
202
+ box-shadow: none;
203
+
204
+ &::-webkit-slider-thumb {
205
+ box-shadow: var(--form-focus-box-shadow);
206
+ }
207
+
208
+ &::-moz-range-thumb {
209
+ box-shadow: var(--form-focus-box-shadow);
210
+ }
211
+ }
212
+
213
+ &::-webkit-slider-runnable-track {
214
+ height: var(--_input-range-marker-size);
215
+ background: transparent;
216
+ }
217
+
218
+ &::-moz-range-track {
219
+ background: transparent;
220
+ }
221
+
222
+ &::-webkit-slider-thumb {
223
+ -webkit-appearance: none;
224
+ appearance: none;
225
+ box-sizing: border-box;
226
+ width: var(--_input-range-marker-size);
227
+ height: var(--_input-range-marker-size);
228
+ border: var(--input-range-marker-selected-width, 0.3rem) solid
229
+ var(--input-range-marker-selected-colour, var(--input-range-accent-colour, var(--theme-accent)));
230
+ border-radius: var(--input-range-marker-border-radius, 50%);
231
+ background: transparent;
232
+ }
233
+
234
+ &::-moz-range-thumb {
235
+ box-sizing: border-box;
236
+ width: var(--_input-range-marker-size);
237
+ height: var(--_input-range-marker-size);
238
+ border: var(--input-range-marker-selected-width, 0.3rem) solid
239
+ var(--input-range-marker-selected-colour, var(--input-range-accent-colour, var(--theme-accent)));
240
+ border-radius: var(--input-range-marker-border-radius, 50%);
241
+ background: transparent;
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ </style>