srcdev-nuxt-components 9.6.6 → 9.6.8

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 (67) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +6 -0
  3. package/.claude/skills/component-naming.md +2 -2
  4. package/.claude/skills/components/input-checkbox-radio.md +19 -3
  5. package/.claude/skills/components/input-checkbox.md +14 -3
  6. package/.claude/skills/components/input-description.md +1 -1
  7. package/.claude/skills/components/input-error.md +1 -1
  8. package/.claude/skills/components/input-label.md +2 -2
  9. package/.claude/skills/components/input-number.md +44 -13
  10. package/.claude/skills/components/input-radio.md +15 -1
  11. package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
  12. package/.claude/skills/components/input-textarea-core.md +4 -4
  13. package/.claude/skills/components/page-hero-highlights.md +22 -18
  14. package/.claude/skills/index.md +6 -6
  15. package/.claude/skills/testing-add-unit-test.md +17 -0
  16. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  17. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  18. package/.vscode/srcdev-component-page-hero-highlights.code-snippets +2 -1
  19. package/app/components/04.templates/page-hero-highlights/CONSUMER-STYLING.md +32 -12
  20. package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +56 -47
  21. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +158 -307
  22. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlightsHeader.stories.ts +10 -0
  23. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +49 -27
  24. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlightsHeader.spec.ts +42 -7
  25. package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +12 -10
  26. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  27. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  28. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  29. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  30. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  31. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  32. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  33. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  34. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  35. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  36. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  37. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  38. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  39. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  40. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  41. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  42. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  43. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +2 -2
  44. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +2 -2
  45. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +42 -2
  46. package/app/components/05.forms/input-number/InputNumber.vue +37 -25
  47. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  48. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  49. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  50. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  51. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  52. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  53. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  54. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  55. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  56. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  57. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  58. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  59. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  60. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  61. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  62. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  63. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +5 -5
  64. package/package.json +1 -1
  65. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  66. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  67. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
@@ -1,9 +1,11 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { ref } from "vue";
3
- import StorybookComponent from "../MultipleCheckboxes.vue";
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import MultipleCheckboxes from "../MultipleCheckboxes.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 MultipleCheckboxesStoryArgs {
8
+ interface StoryArgs {
7
9
  name: string;
8
10
  legend: string;
9
11
  errorMessage: string;
@@ -15,11 +17,21 @@ interface MultipleCheckboxesStoryArgs {
15
17
  direction: "row" | "row-reverse";
16
18
  displayAsDisc: boolean;
17
19
  inputVariant: InputUiVariant;
20
+ descriptionText: string;
21
+ options: IFormMultipleOptions["data"];
18
22
  }
19
23
 
20
- export default {
24
+ const serviceOptions: IFormMultipleOptions["data"] = [
25
+ { id: "1", name: "services", value: "cut", label: "Cut" },
26
+ { id: "2", name: "services", value: "colour", label: "Colour" },
27
+ { id: "3", name: "services", value: "balayage", label: "Balayage" },
28
+ { id: "4", name: "services", value: "styling", label: "Styling" },
29
+ { id: "5", name: "services", value: "treatment", label: "Conditioning treatment" },
30
+ ];
31
+
32
+ const meta: Meta<StoryArgs> = {
21
33
  title: "Components/Forms/Input Checkbox/MultipleCheckboxes",
22
- component: StorybookComponent,
34
+ component: MultipleCheckboxes,
23
35
  argTypes: {
24
36
  name: { control: "text", table: { category: "Basic" } },
25
37
  legend: { control: "text", table: { category: "Basic" } },
@@ -45,6 +57,16 @@ export default {
45
57
  options: ["normal", "outlined", "underlined"],
46
58
  table: { category: "Styling" },
47
59
  },
60
+ descriptionText: {
61
+ control: "text",
62
+ description: "Story-only: fills the descriptionText slot",
63
+ table: { category: "Slots" },
64
+ },
65
+ options: {
66
+ control: "object",
67
+ description: "Story-only: the `data` array of the `fieldData` model",
68
+ table: { category: "Model" },
69
+ },
48
70
  },
49
71
  args: {
50
72
  name: "services",
@@ -58,47 +80,134 @@ export default {
58
80
  direction: "row",
59
81
  displayAsDisc: false,
60
82
  inputVariant: "normal",
83
+ descriptionText: "Select all that apply.",
84
+ options: serviceOptions,
85
+ },
86
+ decorators: [
87
+ (story, context) => ({
88
+ components: { story, CanvasSwitcher },
89
+ setup() {
90
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
91
+ return { canvasName };
92
+ },
93
+ template: `
94
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
95
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
96
+ </div>
97
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
98
+ <story />
99
+ </div>
100
+ `,
101
+ }),
102
+ ],
103
+ parameters: {
104
+ layout: "fullscreen",
105
+ docs: {
106
+ description: {
107
+ component:
108
+ "A fieldset of checkboxes built from the `fieldData` model, bound to an array `v-model`. `isButton` swaps the labelled checkboxes 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. Slots: `descriptionText`, `descriptionHtml`, `checkedIcon`, `itemIcon`.",
109
+ },
110
+ },
61
111
  },
62
- } as Meta<MultipleCheckboxesStoryArgs>;
112
+ };
113
+
114
+ export default meta;
115
+ type Story = StoryObj<StoryArgs>;
63
116
 
64
- const Template: StoryFn<MultipleCheckboxesStoryArgs> = (args) => ({
65
- components: { StorybookComponent },
117
+ const render = (args: StoryArgs) => ({
118
+ components: { MultipleCheckboxes },
66
119
  setup() {
67
- const modelValue = ref<string[]>([]);
68
- const fieldData = ref<IFormMultipleOptions>({
69
- data: [
70
- { id: "1", name: "services", value: "cut", label: "Cut" },
71
- { id: "2", name: "services", value: "colour", label: "Colour" },
72
- { id: "3", name: "services", value: "balayage", label: "Balayage" },
73
- { id: "4", name: "services", value: "styling", label: "Styling" },
74
- { id: "5", name: "services", value: "treatment", label: "Conditioning treatment" },
75
- ],
76
- total: 5,
77
- skip: 0,
78
- limit: 10,
120
+ const componentArgs = computed(() => {
121
+ const { descriptionText: _descriptionText, options: _options, ...rest } = args;
122
+ return rest;
79
123
  });
80
- return { args, modelValue, fieldData };
124
+ const fieldData = computed<IFormMultipleOptions>(() => ({
125
+ data: args.options,
126
+ total: args.options.length,
127
+ skip: 0,
128
+ limit: args.options.length,
129
+ }));
130
+ const modelValue = ref<string[]>([]);
131
+ return { args, componentArgs, fieldData, modelValue };
81
132
  },
82
133
  template: `
83
- <div style="margin: 36px; max-width: 560px;">
84
- <StorybookComponent v-bind="args" v-model="modelValue" v-model:field-data="fieldData">
85
- <template #descriptionText>Select all that apply.</template>
86
- </StorybookComponent>
87
- <p style="margin-top: 2rem; font-family: monospace;">modelValue: {{ JSON.stringify(modelValue) }}</p>
88
- </div>
134
+ <MultipleCheckboxes v-bind="componentArgs" v-model="modelValue" :field-data="fieldData">
135
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
136
+ </MultipleCheckboxes>
137
+ <p style="margin-block-start: 2rem; font-family: monospace; overflow-wrap: anywhere;">modelValue: {{ JSON.stringify(modelValue) }}</p>
89
138
  `,
90
139
  });
91
140
 
92
- export const Default = Template.bind({});
141
+ export const Default: Story = { render };
93
142
 
94
- export const Buttons = Template.bind({});
95
- Buttons.args = { isButton: true, optionsLayout: "inline" };
143
+ export const Buttons: Story = {
144
+ args: { isButton: true, optionsLayout: "inline" },
145
+ render,
146
+ parameters: { docs: { description: { story: "Button tiles in an inline wrap, each with the `itemIcon` decorator." } } },
147
+ };
96
148
 
97
- export const Pills = Template.bind({});
98
- Pills.args = { isButton: true, isPill: true, optionsLayout: "inline" };
149
+ export const Pills: Story = {
150
+ args: { isButton: true, isPill: true, optionsLayout: "inline" },
151
+ render,
152
+ parameters: { docs: { description: { story: "Button tiles with `isPill` for fully rounded ends." } } },
153
+ };
99
154
 
100
- export const Block = Template.bind({});
101
- Block.args = { optionsLayout: "block" };
155
+ export const Block: Story = {
156
+ args: { optionsLayout: "block" },
157
+ render,
158
+ parameters: { docs: { description: { story: "`optionsLayout=\"block\"` stacks the options in one column." } } },
159
+ };
160
+
161
+ export const ErrorState: Story = {
162
+ args: { fieldHasError: true },
163
+ render,
164
+ parameters: {
165
+ docs: { description: { story: "`fieldHasError` shows the error message and links it to every checkbox with `aria-describedby`." } },
166
+ },
167
+ };
102
168
 
103
- export const ErrorState = Template.bind({});
104
- ErrorState.args = { fieldHasError: true };
169
+ export const StressTest: Story = {
170
+ name: "Stress Test (Worst-Case Data)",
171
+ args: {
172
+ name: "dienstleistungen auswahl",
173
+ legend:
174
+ "🎨 Gewünschte Dienstleistungsauswahlmöglichkeitenbeschreibungsüberschrift https://example.com/a/very/long/unbroken/url/that/should/wrap/somewhere <script>alert(1)</script>",
175
+ descriptionText:
176
+ "اختر الخدمات Wählen Sie alle zutreffenden Dienstleistungen aus, die Sie bei Ihrem nächsten Besuch in Anspruch nehmen möchten. <b>not bold</b>",
177
+ errorMessage: "BittewählenSiemindestenseineDienstleistungausbevorSiedasFormularabsenden",
178
+ fieldHasError: true,
179
+ required: true,
180
+ options: [
181
+ { id: "1", name: "s", value: "a", label: "A" },
182
+ { id: "2", name: "s", value: "", label: "" },
183
+ { id: "3", name: "s", value: "long", label: "Haarverlängerungsberatungstermindienstleistungspaket" },
184
+ { id: "4", name: "s", value: "url", label: "https://example.com/a/very/long/unbroken/url/that/should/wrap/somewhere" },
185
+ { id: "5", name: "s", value: "has spaces <b>", label: "<b>Not bold</b> & <i>not italic</i>" },
186
+ { id: "6", name: "s", value: "emoji", label: "💇‍♀️ Colour + cut 🌈" },
187
+ { id: "7", name: "s", value: "rtl", label: "قص الشعر وتصفيفه" },
188
+ {
189
+ id: "8",
190
+ name: "s",
191
+ value: "sentence",
192
+ label: "A conditioning treatment with a scalp massage, a hot towel and a blow-dry finish, booked as one appointment",
193
+ },
194
+ ...Array.from({ length: 22 }, (_, index) => ({
195
+ id: `extra-${index}`,
196
+ name: "s",
197
+ value: `extra-${index}`,
198
+ label: `Option ${index + 9}`,
199
+ })),
200
+ ],
201
+ },
202
+ render,
203
+ parameters: {
204
+ initialCanvas: "mobileCanvas",
205
+ docs: {
206
+ story: { inline: false, height: "900px" },
207
+ description: {
208
+ story:
209
+ "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) toggles its own checkbox. Switch `optionsLayout` to `inline` and `block`, and turn on `isButton`..",
210
+ },
211
+ },
212
+ },
213
+ };
@@ -1,9 +1,11 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { ref } from "vue";
3
- import StorybookComponent from "../SingleCheckbox.vue";
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import SingleCheckbox from "../SingleCheckbox.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
5
  import type { InputUiVariant } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
- interface SingleCheckboxStoryArgs {
8
+ interface StoryArgs {
7
9
  name: string;
8
10
  legend: string;
9
11
  label: string;
@@ -11,11 +13,12 @@ interface SingleCheckboxStoryArgs {
11
13
  required: boolean;
12
14
  fieldHasError: boolean;
13
15
  inputVariant: InputUiVariant;
16
+ descriptionText: string;
14
17
  }
15
18
 
16
- export default {
19
+ const meta: Meta<StoryArgs> = {
17
20
  title: "Components/Forms/Input Checkbox/SingleCheckbox",
18
- component: StorybookComponent,
21
+ component: SingleCheckbox,
19
22
  argTypes: {
20
23
  name: { control: "text", table: { category: "Basic" } },
21
24
  legend: { control: "text", table: { category: "Basic" } },
@@ -28,6 +31,11 @@ export default {
28
31
  options: ["normal", "outlined", "underlined"],
29
32
  table: { category: "Styling" },
30
33
  },
34
+ descriptionText: {
35
+ control: "text",
36
+ description: "Story-only: fills the descriptionText slot",
37
+ table: { category: "Slots" },
38
+ },
31
39
  },
32
40
  args: {
33
41
  name: "terms",
@@ -37,39 +45,108 @@ export default {
37
45
  required: true,
38
46
  fieldHasError: false,
39
47
  inputVariant: "normal",
48
+ descriptionText: "",
49
+ },
50
+ decorators: [
51
+ (story, context) => ({
52
+ components: { story, CanvasSwitcher },
53
+ setup() {
54
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
55
+ return { canvasName };
56
+ },
57
+ template: `
58
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
59
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
60
+ </div>
61
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
62
+ <story />
63
+ </div>
64
+ `,
65
+ }),
66
+ ],
67
+ parameters: {
68
+ layout: "fullscreen",
69
+ docs: {
70
+ description: {
71
+ component:
72
+ "A fieldset holding one checkbox, for consent or opt-in fields. `v-model` toggles between `trueValue` and `falseValue` (default `true`/`false`); `required` puts native `required` on the checkbox. Slots: `labelContent` (rich label, e.g. a link), `descriptionText`, `descriptionHtml`, `checkedIcon`.",
73
+ },
74
+ },
40
75
  },
41
- } as Meta<SingleCheckboxStoryArgs>;
76
+ };
42
77
 
43
- const Template: StoryFn<SingleCheckboxStoryArgs> = (args) => ({
44
- components: { StorybookComponent },
78
+ export default meta;
79
+ type Story = StoryObj<StoryArgs>;
80
+
81
+ const render = (args: StoryArgs) => ({
82
+ components: { SingleCheckbox },
45
83
  setup() {
84
+ const componentArgs = computed(() => {
85
+ const { descriptionText: _descriptionText, ...rest } = args;
86
+ return rest;
87
+ });
46
88
  const modelValue = ref(false);
47
- return { args, modelValue };
89
+ return { args, componentArgs, modelValue };
48
90
  },
49
91
  template: `
50
- <div style="margin: 36px; max-width: 480px;">
51
- <StorybookComponent v-bind="args" v-model="modelValue" />
52
- <p style="margin-top: 2rem; font-family: monospace;">modelValue: {{ modelValue }}</p>
53
- </div>
92
+ <SingleCheckbox v-bind="componentArgs" v-model="modelValue">
93
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
94
+ </SingleCheckbox>
95
+ <p style="margin-block-start: 2rem; font-family: monospace;">modelValue: {{ modelValue }}</p>
54
96
  `,
55
97
  });
56
98
 
57
- export const Default = Template.bind({});
58
-
59
- export const ErrorState = Template.bind({});
60
- ErrorState.args = { fieldHasError: true };
99
+ export const Default: Story = { render };
61
100
 
62
- export const RichLabel: StoryFn<SingleCheckboxStoryArgs> = (args) => ({
63
- components: { StorybookComponent },
64
- setup() {
65
- const modelValue = ref(false);
66
- return { args, modelValue };
101
+ export const ErrorState: Story = {
102
+ args: { fieldHasError: true },
103
+ render,
104
+ parameters: {
105
+ docs: { description: { story: "`fieldHasError` shows the error message and links it to the checkbox with `aria-describedby`." } },
67
106
  },
68
- template: `
69
- <div style="margin: 36px; max-width: 480px;">
70
- <StorybookComponent v-bind="args" v-model="modelValue">
107
+ };
108
+
109
+ export const RichLabel: Story = {
110
+ render: (args: StoryArgs) => ({
111
+ components: { SingleCheckbox },
112
+ setup() {
113
+ const componentArgs = computed(() => {
114
+ const { descriptionText: _descriptionText, ...rest } = args;
115
+ return rest;
116
+ });
117
+ const modelValue = ref(false);
118
+ return { componentArgs, modelValue };
119
+ },
120
+ template: `
121
+ <SingleCheckbox v-bind="componentArgs" v-model="modelValue">
71
122
  <template #labelContent>I agree to the <a href="#">terms and conditions</a></template>
72
- </StorybookComponent>
73
- </div>
74
- `,
75
- });
123
+ </SingleCheckbox>
124
+ `,
125
+ }),
126
+ parameters: { docs: { description: { story: "The `labelContent` slot replaces the `label` prop, here with a link." } } },
127
+ };
128
+
129
+ export const StressTest: Story = {
130
+ name: "Stress Test (Worst-Case Data)",
131
+ args: {
132
+ name: "marketing einwilligung",
133
+ legend:
134
+ "📨 Einwilligungserklärungzurdatenschutzkonformenverarbeitungpersonenbezogenerdaten https://example.com/a/very/long/unbroken/url <script>alert(1)</script>",
135
+ label:
136
+ "Ich stimme zu: Datenschutzgrundverordnungseinwilligungsbestätigung https://example.com/privacy/policy/with/a/long/path <b>not bold</b> قص",
137
+ descriptionText:
138
+ "سياسة الخصوصية Sie können Ihre Einwilligung jederzeit widerrufen, indem Sie uns eine kurze Nachricht schicken. 🙂",
139
+ errorMessage: "BitteakzeptierenSiedieDatenschutzbestimmungenbevorSiefortfahren",
140
+ fieldHasError: true,
141
+ },
142
+ render,
143
+ parameters: {
144
+ initialCanvas: "mobileCanvas",
145
+ docs: {
146
+ description: {
147
+ story:
148
+ "Long unbroken German and URL text in the legend, label and error, emoji, right-to-left text and HTML-like text. Check at every canvas width: everything wraps inside the dashed outline, markup renders as text, and clicking anywhere on the label toggles the checkbox. Also try an empty label and a single character.",
149
+ },
150
+ },
151
+ },
152
+ };
@@ -1,4 +1,5 @@
1
1
  // https://nuxt.com/docs/getting-started/testing#unit-testing
2
+ import { defineComponent } from "vue";
2
3
  import type { VueWrapper } from "@vue/test-utils";
3
4
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
5
  import type { FormUiTheme, OptionsLayout } from "~/types/forms/types.forms";
@@ -25,6 +26,11 @@ const initialSlots = {
25
26
  };
26
27
 
27
28
  let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
29
+
30
+ afterEach(() => {
31
+ wrapper?.unmount();
32
+ vi.restoreAllMocks();
33
+ });
28
34
  const wrapperFactory = (propsData = {}, slotsData = {}) => {
29
35
  const mockPropsData = { ...initialPropsData, ...propsData };
30
36
  const mockSlotsData = { ...initialSlots, ...slotsData };
@@ -84,10 +90,38 @@ describe("MultipleCheckboxes Component", () => {
84
90
  });
85
91
  });
86
92
 
87
- it("gives each checkbox an id derived from name and value", async () => {
88
- wrapper = await wrapperFactory();
89
- const first = wrapper.find('input[type="checkbox"]');
90
- expect(first.attributes("id")).toBe(`tags-${tagsData.data[0]!.value}`);
93
+ it("gives each button checkbox a unique, space-free id linked to its label", async () => {
94
+ wrapper = await wrapperFactory({ isButton: true });
95
+ const ids = wrapper.findAll('input[type="checkbox"]').map((checkbox) => checkbox.attributes("id")!);
96
+ expect(new Set(ids).size).toBe(tagsData.data.length);
97
+ ids.forEach((id) => expect(id).not.toMatch(/\s/));
98
+ ids.forEach((id) => expect(wrapper.find(`label[for="${id}"]`).exists()).toBe(true));
99
+ });
100
+
101
+ it("keeps option ids unique across two groups sharing a name and values", async () => {
102
+ const Host = defineComponent({
103
+ components: { ComponentUnderTest },
104
+ setup: () => ({ props: initialPropsData }),
105
+ template: `
106
+ <div>
107
+ <ComponentUnderTest v-bind="props" :model-value="[]" />
108
+ <ComponentUnderTest v-bind="props" :model-value="[]" />
109
+ </div>
110
+ `,
111
+ });
112
+ const host = await mountSuspended(Host);
113
+ const ids = host.findAll('input[type="checkbox"]').map((checkbox) => checkbox.attributes("id"));
114
+ expect(ids.length).toBe(tagsData.data.length * 2);
115
+ expect(new Set(ids).size).toBe(ids.length);
116
+ host.unmount();
117
+ });
118
+
119
+ it("builds ids that don't depend on option values containing spaces", async () => {
120
+ wrapper = await wrapperFactory({
121
+ isButton: true,
122
+ fieldData: { ...tagsData, data: [{ id: "x", name: "tags", value: "has spaces <b>", label: "Spaces" }] },
123
+ });
124
+ expect(wrapper.find('input[type="checkbox"]').attributes("id")).not.toMatch(/\s/);
91
125
  });
92
126
 
93
127
  it("adds each clicked value to the model", async () => {
@@ -12,6 +12,11 @@ const initialPropsData = {
12
12
  };
13
13
 
14
14
  let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
15
+
16
+ afterEach(() => {
17
+ wrapper?.unmount();
18
+ vi.restoreAllMocks();
19
+ });
15
20
  const wrapperFactory = (propsData = {}, slotsData = {}) =>
16
21
  mountSuspended(ComponentUnderTest, {
17
22
  attachTo: document.body,
@@ -67,8 +67,7 @@ Also reads `--input-checkbox-decorator-icon-size` (decorator icon `font-size`, `
67
67
  `--input-font-size` (label).
68
68
 
69
69
  Private (not public API): `--_transition-duration` (the control's resolved duration, shared by two
70
- transitions), `--_icon-size` (the button shrinks the inner control's checked icon through it) and
71
- `--_white-space` (the button label's wrapping, swapped by `data-options-layout="inline"`).
70
+ transitions) and `--_icon-size` (the button shrinks the inner control's checked icon through it).
72
71
 
73
72
  > **Changed 2026-09-27**:
74
73
  >
@@ -80,6 +79,14 @@ transitions), `--_icon-size` (the button shrinks the inner control's checked ico
80
79
  > so both tokens take effect for the first time: the `+` renders at `2rem` and the checked icon
81
80
  > inside a button at `--input-checked-icon-size` minus `0.8rem`.
82
81
  > - `--input-checkbox-label-color` is new (was unstyled, so `inherit`).
82
+ >
83
+ > **Changed 2026-10-16**: long labels wrap instead of overflowing. Both labels
84
+ > (`.input-checkbox-radio-field-label`, `.input-checkbox-radio-button-label`) and their roots set
85
+ > `min-inline-size: 0`, the labels set `overflow-wrap: anywhere`, and the control and decorator
86
+ > icon no longer shrink (`flex-shrink: 0`). The button label used to be `nowrap` under
87
+ > `data-options-layout="inline"` (private `--_white-space`, now removed), so an unbroken label
88
+ > overflowed the row. In a wrapping row a short label still sits on one line; if you relied on
89
+ > `nowrap`, set `white-space: nowrap` on `.input-checkbox-radio-button-label` yourself.
83
90
 
84
91
  ---
85
92
 
@@ -95,7 +102,7 @@ transitions), `--_icon-size` (the button shrinks the inner control's checked ico
95
102
  | `data-invalid` | all three roots | The field has an error |
96
103
  | `data-pill` | `.input-checkbox-radio-button` | `is-pill` |
97
104
  | `data-direction="row" \| "row-reverse"` | `.input-checkbox-radio-button` | Always, mirrors `direction` |
98
- | `data-options-layout` | `.input-checkbox-radio-button` | Always, mirrors `options-layout` (`inline` stops the label wrapping) |
105
+ | `data-options-layout` | `.input-checkbox-radio-button` | Always, mirrors `options-layout` (a hook only: it no longer changes wrapping) |
99
106
 
100
107
  Inner elements: `.input-checkbox-radio-icon-slot` (holds the `checkedIcon` slot or the default
101
108
  `.input-checkbox-radio-icon`), `.input-checkbox-radio-input` (the hidden native input),
@@ -99,6 +99,7 @@ watch(
99
99
 
100
100
  height: var(--input-checked-element-size);
101
101
  width: var(--input-checked-element-size);
102
+ flex-shrink: 0;
102
103
 
103
104
  transition: all var(--_transition-duration) ease-in-out;
104
105
 
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <label
3
- :for="id"
3
+ :for="inputId"
4
4
  class="input-checkbox-radio-button"
5
5
  :class="elementClasses"
6
6
  :data-theme="theme"
@@ -10,7 +10,7 @@
10
10
  :data-pill="isPill ? '' : undefined"
11
11
  >
12
12
  <InputCheckboxRadio
13
- :id
13
+ :id="inputId"
14
14
  v-model="modelValue"
15
15
  :is-button="true"
16
16
  :type
@@ -44,7 +44,7 @@
44
44
  import type { FormUiTheme, InputUiVariant, OptionsLayout } from "~/types/forms/types.forms";
45
45
 
46
46
  interface Props {
47
- id: string;
47
+ id?: string;
48
48
  type: "checkbox" | "radio";
49
49
  name: string;
50
50
  label?: string;
@@ -65,6 +65,7 @@ interface Props {
65
65
  }
66
66
 
67
67
  const props = withDefaults(defineProps<Props>(), {
68
+ id: undefined,
68
69
  label: "",
69
70
  required: false,
70
71
  theme: "default",
@@ -84,6 +85,9 @@ const props = withDefaults(defineProps<Props>(), {
84
85
 
85
86
  const modelValue = defineModel<(string | number | boolean)[] | string | number | boolean | undefined>({ required: true });
86
87
 
88
+ const generatedId = useId();
89
+ const inputId = computed(() => props.id || generatedId);
90
+
87
91
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
88
92
 
89
93
  watch(
@@ -97,9 +101,8 @@ watch(
97
101
  <style lang="css">
98
102
  @layer components {
99
103
  .input-checkbox-radio-button {
100
- --_white-space: wrap;
101
-
102
104
  display: flex;
105
+ min-inline-size: 0;
103
106
  align-items: center;
104
107
  justify-content: space-between;
105
108
  gap: var(--input-checkbox-button-gap, 1rem);
@@ -122,10 +125,6 @@ watch(
122
125
  border-radius: var(--input-checkbox-button-border-radius-pill, 100vw);
123
126
  }
124
127
 
125
- &[data-options-layout="inline"] {
126
- --_white-space: nowrap;
127
- }
128
-
129
128
  &:hover {
130
129
  background-color: var(--input-checkbox-button-surface-hover, var(--theme-input-surface-hover));
131
130
  outline-color: var(--input-checkbox-button-ring-hover, var(--theme-ring));
@@ -155,12 +154,14 @@ watch(
155
154
  text-align: center;
156
155
  padding-block: var(--input-checkbox-button-label-padding-block, 0.8rem);
157
156
  padding-inline: var(--input-checkbox-button-label-padding-inline, 0.8rem);
158
- white-space: var(--_white-space);
157
+ min-inline-size: 0;
158
+ overflow-wrap: anywhere;
159
159
  cursor: pointer;
160
160
  }
161
161
 
162
162
  .input-checkbox-radio-button-icon {
163
163
  display: flex;
164
+ flex-shrink: 0;
164
165
  align-items: center;
165
166
  justify-content: center;
166
167
  color: var(--input-checkbox-button-icon-color, var(--theme-text));
@@ -85,11 +85,14 @@ watch(
85
85
  gap: var(--input-checked-icon-gap);
86
86
  min-height: var(--input-min-height);
87
87
  user-select: none;
88
+ min-inline-size: 0;
88
89
 
89
90
  .input-checkbox-radio-field-label {
90
91
  display: flex;
91
92
  flex: 1;
92
93
  align-items: center;
94
+ min-inline-size: 0;
95
+ overflow-wrap: anywhere;
93
96
  color: var(--input-checkbox-label-color, inherit);
94
97
  padding-block: var(--input-checkbox-label-padding-block);
95
98
  padding-inline: var(--input-checkbox-label-padding-inline);