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,9 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
1
  import { ref } from "vue";
3
- import StorybookComponent from "../InputCheckboxRadio.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputCheckboxRadio from "../InputCheckboxRadio.vue";
4
4
  import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
5
5
 
6
- interface InputCheckboxRadioStoryArgs {
6
+ interface StoryArgs {
7
7
  type: "checkbox" | "radio";
8
8
  id: string;
9
9
  name: string;
@@ -15,9 +15,20 @@ interface InputCheckboxRadioStoryArgs {
15
15
  inputVariant: InputUiVariant;
16
16
  }
17
17
 
18
- export default {
18
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
19
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
20
+ const parentNote = `
21
+ <p style="${storyNoteStyle}">
22
+ This is the bare control with no label. With a text label it's the
23
+ <a href="/?path=/story/components-forms-input-checkbox-radio-inputcheckboxradiofield--checkbox" target="_top">InputCheckboxRadioField</a>;
24
+ drawn as a button or pill it's the
25
+ <a href="/?path=/story/components-forms-input-checkbox-radio-inputcheckboxradiobutton--option-group" target="_top">InputCheckboxRadioButton</a>.
26
+ </p>
27
+ `;
28
+
29
+ const meta: Meta<StoryArgs> = {
19
30
  title: "Components/Forms/Input Checkbox Radio/InputCheckboxRadio",
20
- component: StorybookComponent,
31
+ component: InputCheckboxRadio,
21
32
  argTypes: {
22
33
  type: {
23
34
  control: { type: "select" },
@@ -62,29 +73,45 @@ export default {
62
73
  isButton: false,
63
74
  inputVariant: "normal",
64
75
  },
65
- } as Meta<InputCheckboxRadioStoryArgs>;
76
+ parameters: {
77
+ docs: {
78
+ description: {
79
+ component:
80
+ "The checkbox or radio control every form checkbox and radio is built from: a styled square or circle stacked over a hidden native input. It has no label, so pair its `id` with your own `label[for]`, or use `InputCheckboxRadioField` / `InputCheckboxRadioButton`. Slot: `checkedIcon`. No stress story here: the control renders no text, and long-label behaviour is covered by the Field and Button stress stories.",
81
+ },
82
+ },
83
+ },
84
+ };
85
+
86
+ export default meta;
87
+ type Story = StoryObj<StoryArgs>;
66
88
 
67
- const Template: StoryFn<InputCheckboxRadioStoryArgs> = (args) => ({
68
- components: { StorybookComponent },
89
+ const render = (args: StoryArgs) => ({
90
+ components: { InputCheckboxRadio },
69
91
  setup() {
70
92
  const modelValue = ref(false);
71
93
  return { args, modelValue };
72
94
  },
73
95
  template: `
74
96
  <div style="margin: 36px;">
75
- <StorybookComponent v-bind="args" v-model="modelValue" />
97
+ ${parentNote}
98
+ <InputCheckboxRadio v-bind="args" v-model="modelValue" />
76
99
  </div>
77
100
  `,
78
101
  });
79
102
 
80
- export const Checkbox = Template.bind({});
81
- Checkbox.args = { type: "checkbox" };
103
+ export const Checkbox: Story = { args: { type: "checkbox" }, render };
82
104
 
83
- export const Radio = Template.bind({});
84
- Radio.args = { type: "radio" };
105
+ export const Radio: Story = { args: { type: "radio" }, render };
85
106
 
86
- export const CheckboxAsDisc = Template.bind({});
87
- CheckboxAsDisc.args = { type: "checkbox", isButton: true, displayAsDisc: true };
107
+ export const CheckboxAsDisc: Story = {
108
+ args: { type: "checkbox", isButton: true, displayAsDisc: true },
109
+ render,
110
+ parameters: { docs: { description: { story: "`displayAsDisc` rounds a checkbox, but only in button mode (`isButton`)." } } },
111
+ };
88
112
 
89
- export const ErrorState = Template.bind({});
90
- ErrorState.args = { type: "checkbox", fieldHasError: true, theme: "error" };
113
+ export const ErrorState: Story = {
114
+ args: { type: "checkbox", fieldHasError: true, theme: "error" },
115
+ render,
116
+ parameters: { docs: { description: { story: "`fieldHasError` sets `aria-invalid` on the input and `data-invalid` on the root." } } },
117
+ };
@@ -1,26 +1,53 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
1
  import { defineComponent, ref } from "vue";
3
- import StorybookComponent from "../InputCheckboxRadioButton.vue";
2
+ import type { PropType } from "vue";
3
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
4
+ import InputCheckboxRadioButton from "../InputCheckboxRadioButton.vue";
5
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
6
+ import type { OptionsLayout } from "~/types/forms/types.forms.d";
7
+ import type { MediaCanvas } from "~/types/components";
4
8
 
5
- interface InputCheckboxRadioButtonStoryArgs {
9
+ interface StoryArgs {
6
10
  type: "checkbox" | "radio";
7
11
  label: string;
8
12
  isPill: boolean;
9
13
  fieldHasError: boolean;
10
14
  displayAsDisc: boolean;
11
15
  direction: "row" | "row-reverse";
16
+ optionsLayout: OptionsLayout;
17
+ options: string[];
12
18
  }
13
19
 
14
- export default {
20
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
21
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
22
+ const parentNote = `
23
+ <p style="${storyNoteStyle}">
24
+ The group components draw each option with this when <code>isButton</code> is on, and add the
25
+ fieldset, legend, description and error: see the
26
+ <a href="/?path=/story/components-forms-input-checkbox-multiplecheckboxes--buttons" target="_top">MultipleCheckboxes</a>
27
+ and
28
+ <a href="/?path=/story/components-forms-input-radio-multipleradiobuttons--buttons" target="_top">MultipleRadiobuttons</a>
29
+ Buttons stories.
30
+ </p>
31
+ `;
32
+
33
+ const serviceOptions = ["Balayage", "Highlights", "Half Head Highlights", "Full Colour", "Lowlights", "Toner & Gloss"];
34
+
35
+ const layoutStyles: Record<OptionsLayout, string> = {
36
+ "equal-widths": "display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); gap: 0.8rem;",
37
+ inline: "display: flex; flex-wrap: wrap; gap: 0.8rem;",
38
+ block: "display: flex; flex-direction: column; gap: 0.8rem;",
39
+ };
40
+
41
+ const meta: Meta<StoryArgs> = {
15
42
  title: "Components/Forms/Input Checkbox Radio/InputCheckboxRadioButton",
16
- component: StorybookComponent,
43
+ component: InputCheckboxRadioButton,
17
44
  argTypes: {
18
45
  type: {
19
46
  control: { type: "select" },
20
47
  options: ["checkbox", "radio"],
21
48
  table: { category: "Basic" },
22
49
  },
23
- label: { control: "text", table: { category: "Basic" } },
50
+ label: { control: "text", description: "Single-option stories only", table: { category: "Basic" } },
24
51
  isPill: { control: "boolean", table: { category: "Styling" } },
25
52
  fieldHasError: { control: "boolean", table: { category: "States" } },
26
53
  displayAsDisc: { control: "boolean", table: { category: "Styling" } },
@@ -30,6 +57,18 @@ export default {
30
57
  description: "row-reverse puts the control on the right",
31
58
  table: { category: "Styling" },
32
59
  },
60
+ optionsLayout: {
61
+ control: { type: "select" },
62
+ options: ["equal-widths", "inline", "block"],
63
+ description:
64
+ "Rendered as `data-options-layout`. In the group stories it also lays out the story's wrapper the way `MultipleCheckboxes` does",
65
+ table: { category: "Styling" },
66
+ },
67
+ options: {
68
+ control: "object",
69
+ description: "Story-only: the option labels in the group stories",
70
+ table: { category: "Story data" },
71
+ },
33
72
  },
34
73
  args: {
35
74
  type: "checkbox",
@@ -38,93 +77,121 @@ export default {
38
77
  fieldHasError: false,
39
78
  displayAsDisc: false,
40
79
  direction: "row",
80
+ optionsLayout: "equal-widths",
81
+ options: serviceOptions,
41
82
  },
42
- } as Meta<InputCheckboxRadioButtonStoryArgs>;
43
-
44
- const Template: StoryFn<InputCheckboxRadioButtonStoryArgs> = (args) => ({
45
- components: { StorybookComponent },
46
- setup() {
47
- const modelValue = ref(false);
48
- return { args, modelValue };
83
+ decorators: [
84
+ (story, context) => ({
85
+ components: { story, CanvasSwitcher },
86
+ setup() {
87
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
88
+ return { canvasName };
89
+ },
90
+ template: `
91
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
92
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
93
+ </div>
94
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
95
+ <story />
96
+ </div>
97
+ `,
98
+ }),
99
+ ],
100
+ parameters: {
101
+ layout: "fullscreen",
102
+ docs: {
103
+ description: {
104
+ component:
105
+ "One checkbox or radio option drawn as a button or pill: a `<label>` holding the control, a centred label and a decorator icon. `id` is optional and generated with `useId()` when omitted. Props: `type`, `name`, `label`, `trueValue`, `multipleOptions`, `isPill`, `direction`, `displayAsDisc`, `optionsLayout`, `itemIcon`. Slots: `labelContent`, `checkedIcon`, `itemIcon`. Usually used through the `isButton` mode of `MultipleCheckboxes` and `MultipleRadiobuttons`: see the [MultipleCheckboxes Buttons](?path=/story/components-forms-input-checkbox-multiplecheckboxes--buttons) story.",
106
+ },
107
+ },
49
108
  },
50
- template: `
51
- <div style="margin: 36px; max-width: 260px;">
52
- <StorybookComponent
53
- id="single-option"
54
- name="single"
55
- :type="args.type"
56
- :label="args.label"
57
- :is-pill="args.isPill"
58
- :field-has-error="args.fieldHasError"
59
- :display-as-disc="args.displayAsDisc"
60
- :direction="args.direction"
61
- v-model="modelValue"
62
- />
63
- </div>
64
- `,
65
- });
109
+ };
110
+
111
+ export default meta;
112
+ type Story = StoryObj<StoryArgs>;
66
113
 
67
- export const Default = Template.bind({});
114
+ export const Default: Story = {
115
+ render: (args: StoryArgs) => ({
116
+ components: { InputCheckboxRadioButton },
117
+ setup() {
118
+ const modelValue = ref(false);
119
+ return { args, modelValue };
120
+ },
121
+ template: `
122
+ ${parentNote}
123
+ <div style="max-width: 260px;">
124
+ <InputCheckboxRadioButton
125
+ name="single"
126
+ :type="args.type"
127
+ :label="args.label"
128
+ :is-pill="args.isPill"
129
+ :field-has-error="args.fieldHasError"
130
+ :display-as-disc="args.displayAsDisc"
131
+ :direction="args.direction"
132
+ :options-layout="args.optionsLayout"
133
+ v-model="modelValue"
134
+ />
135
+ </div>
136
+ `,
137
+ }),
138
+ argTypes: { options: { table: { disable: true } } },
139
+ };
68
140
 
69
- // "Services of interest" pattern — the actual multi-option checkbox-pill layout this component
70
- // is normally used in (as opposed to Default's single isolated control).
71
141
  // Selection state lives in a child keyed on type, so switching checkbox/radio remounts it with
72
142
  // the right empty value (an array for checkboxes, a string for radios). A watch on args.type
73
143
  // isn't reliable here: the template sees the new arg, but the watcher doesn't fire.
74
- const ServiceOptionGroup = defineComponent({
75
- components: { StorybookComponent },
144
+ const OptionGroupDemo = defineComponent({
145
+ components: { InputCheckboxRadioButton },
76
146
  props: {
77
- type: { type: String as () => "checkbox" | "radio", required: true },
147
+ type: { type: String as PropType<"checkbox" | "radio">, required: true },
148
+ options: { type: Array as PropType<string[]>, required: true },
149
+ optionsLayout: { type: String as PropType<OptionsLayout>, required: true },
78
150
  isPill: Boolean,
79
151
  fieldHasError: Boolean,
80
152
  displayAsDisc: Boolean,
81
- direction: { type: String as () => "row" | "row-reverse", default: "row" },
153
+ direction: { type: String as PropType<"row" | "row-reverse">, default: "row" },
82
154
  },
83
155
  setup(props) {
84
- const services = [
85
- "Balayage",
86
- "Highlights",
87
- "Half Head Highlights",
88
- "Full Colour",
89
- "Lowlights",
90
- "Toner & Gloss",
91
- ];
92
156
  const selected = ref<string[] | string>(props.type === "checkbox" ? [] : "");
93
- return { services, selected };
157
+ return { selected, layoutStyles };
94
158
  },
95
159
  template: `
96
160
  <div>
97
- <div style="margin: 36px; max-width: 480px; display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
98
- <StorybookComponent
99
- v-for="service in services"
100
- :key="service"
101
- :id="'service-' + service"
161
+ <div :style="layoutStyles[optionsLayout]">
162
+ <InputCheckboxRadioButton
163
+ v-for="(option, index) in options"
164
+ :key="index"
102
165
  name="services"
103
166
  :type="type"
104
- :label="service"
105
- :true-value="service"
167
+ :label="option"
168
+ :true-value="option"
106
169
  :multiple-options="type === 'checkbox'"
107
170
  :is-pill="isPill"
108
171
  :field-has-error="fieldHasError"
109
172
  :display-as-disc="displayAsDisc"
110
173
  :direction="direction"
174
+ :options-layout="optionsLayout"
111
175
  v-model="selected"
112
176
  />
113
177
  </div>
114
- <p style="margin: 0 36px; font-family: monospace;">selected: {{ JSON.stringify(selected) }}</p>
178
+ <p style="margin-block-start: 1.6rem; font-family: monospace; overflow-wrap: anywhere;">selected: {{ JSON.stringify(selected) }}</p>
115
179
  </div>
116
180
  `,
117
181
  });
118
182
 
119
- const OptionGroupTemplate: StoryFn<InputCheckboxRadioButtonStoryArgs> = (args) => ({
120
- components: { ServiceOptionGroup },
183
+ const renderGroup = (args: StoryArgs) => ({
184
+ components: { OptionGroupDemo },
121
185
  setup() {
122
186
  return { args };
123
187
  },
124
188
  template: `
125
- <ServiceOptionGroup
189
+ ${parentNote}
190
+ <OptionGroupDemo
126
191
  :key="args.type"
127
192
  :type="args.type"
193
+ :options="args.options"
194
+ :options-layout="args.optionsLayout"
128
195
  :is-pill="args.isPill"
129
196
  :field-has-error="args.fieldHasError"
130
197
  :display-as-disc="args.displayAsDisc"
@@ -133,5 +200,44 @@ const OptionGroupTemplate: StoryFn<InputCheckboxRadioButtonStoryArgs> = (args) =
133
200
  `,
134
201
  });
135
202
 
136
- export const OptionGroup = OptionGroupTemplate.bind({});
137
- OptionGroup.argTypes = { label: { table: { disable: true } } };
203
+ export const OptionGroup: Story = {
204
+ render: renderGroup,
205
+ argTypes: { label: { table: { disable: true } } },
206
+ parameters: {
207
+ docs: {
208
+ description: {
209
+ story: "A group of options sharing one `name` and model, the way the group components render them. Switch `type` and `optionsLayout`.",
210
+ },
211
+ },
212
+ },
213
+ };
214
+
215
+ export const StressTest: Story = {
216
+ name: "Stress Test (Worst-Case Data)",
217
+ args: {
218
+ optionsLayout: "inline",
219
+ isPill: true,
220
+ options: [
221
+ "A",
222
+ "",
223
+ "Haarverlängerungsberatungstermindienstleistungspaket",
224
+ "https://example.com/a/very/long/unbroken/url/that/should/wrap/somewhere",
225
+ "<b>Not bold</b> & <i>not italic</i>",
226
+ "💇‍♀️ Colour + cut 🌈",
227
+ "قص الشعر وتصفيفه",
228
+ "A conditioning treatment with a scalp massage, a hot towel and a blow-dry finish",
229
+ ...Array.from({ length: 16 }, (_, index) => `Option ${index + 9}`),
230
+ ],
231
+ },
232
+ render: renderGroup,
233
+ argTypes: { label: { table: { disable: true } } },
234
+ parameters: {
235
+ initialCanvas: "mobileCanvas",
236
+ docs: {
237
+ description: {
238
+ story:
239
+ "24 pill options with an empty label, a single character, a long unbroken German word and URL, HTML-like text, emoji, right-to-left text and a long sentence, laid out inline. Check at every canvas width: no option pokes out of the dashed outline (long labels wrap inside their pill instead), the control and decorator icon keep their size, short labels stay on one line, and markup renders as text. Switch `optionsLayout`, `direction` and `type`.",
240
+ },
241
+ },
242
+ },
243
+ };
@@ -1,9 +1,11 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
1
  import { computed, ref } from "vue";
3
- import StorybookComponent from "../InputCheckboxRadioField.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputCheckboxRadioField from "../InputCheckboxRadioField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
5
  import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
- interface InputCheckboxRadioFieldStoryArgs {
8
+ interface StoryArgs {
7
9
  type: "checkbox" | "radio";
8
10
  name: string;
9
11
  label: string;
@@ -15,9 +17,22 @@ interface InputCheckboxRadioFieldStoryArgs {
15
17
  useLabelSlot: boolean;
16
18
  }
17
19
 
18
- export default {
20
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
21
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
22
+ const parentNote = `
23
+ <p style="${storyNoteStyle}">
24
+ Wrapped in a fieldset with a legend, description and error message, this is the
25
+ <a href="/?path=/story/components-forms-input-checkbox-singlecheckbox--default" target="_top">SingleCheckbox</a>;
26
+ a data-driven group of them is
27
+ <a href="/?path=/story/components-forms-input-checkbox-multiplecheckboxes--default" target="_top">MultipleCheckboxes</a>
28
+ or
29
+ <a href="/?path=/story/components-forms-input-radio-multipleradiobuttons--default" target="_top">MultipleRadiobuttons</a>.
30
+ </p>
31
+ `;
32
+
33
+ const meta: Meta<StoryArgs> = {
19
34
  title: "Components/Forms/Input Checkbox Radio/InputCheckboxRadioField",
20
- component: StorybookComponent,
35
+ component: InputCheckboxRadioField,
21
36
  argTypes: {
22
37
  type: { control: { type: "select" }, options: ["checkbox", "radio"], table: { category: "Basic" } },
23
38
  name: { control: "text", table: { category: "Basic" } },
@@ -25,7 +40,7 @@ export default {
25
40
  required: { control: "boolean", table: { category: "Basic" } },
26
41
  useLabelSlot: {
27
42
  control: "boolean",
28
- description: "Use the labelContent slot (rich label with a link) instead of the label prop",
43
+ description: "Story-only: use the labelContent slot (rich label with a link) instead of the label prop",
29
44
  table: { category: "Slots" },
30
45
  },
31
46
  fieldHasError: { control: "boolean", description: "Rendered as data-invalid", table: { category: "States" } },
@@ -41,8 +56,9 @@ export default {
41
56
  },
42
57
  labelColor: {
43
58
  control: "color",
44
- description: "Sets --input-checkbox-label-color on the story wrapper",
45
- table: { category: "Tokens" },
59
+ description:
60
+ "**Story control, not a prop.** Sets `--input-checkbox-label-color` on the story wrapper. In your CSS: `.my-form { --input-checkbox-label-color: #333; }`",
61
+ table: { category: "CSS tokens (story only, set in your CSS)" },
46
62
  },
47
63
  },
48
64
  args: {
@@ -56,10 +72,39 @@ export default {
56
72
  labelColor: "",
57
73
  useLabelSlot: false,
58
74
  },
59
- } as Meta<InputCheckboxRadioFieldStoryArgs>;
75
+ decorators: [
76
+ (story, context) => ({
77
+ components: { story, CanvasSwitcher },
78
+ setup() {
79
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
80
+ return { canvasName };
81
+ },
82
+ template: `
83
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
84
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
85
+ </div>
86
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
87
+ <story />
88
+ </div>
89
+ `,
90
+ }),
91
+ ],
92
+ parameters: {
93
+ layout: "fullscreen",
94
+ docs: {
95
+ description: {
96
+ component:
97
+ "A `<label>` holding the checkbox or radio control and its text, so the whole row is clickable. It generates its own id with `useId()`. Props: `type`, `name`, `label`, `trueValue`/`falseValue`, `multipleOptions` (array model for a checkbox group), `required`, `fieldHasError`. Slots: `labelContent` (rich label), `checkedIcon`. Usually used through `SingleCheckbox`, `MultipleCheckboxes` or `MultipleRadiobuttons`: see the [SingleCheckbox](?path=/story/components-forms-input-checkbox-singlecheckbox--default) story.",
98
+ },
99
+ },
100
+ },
101
+ };
60
102
 
61
- const Template: StoryFn<InputCheckboxRadioFieldStoryArgs> = (args) => ({
62
- components: { StorybookComponent },
103
+ export default meta;
104
+ type Story = StoryObj<StoryArgs>;
105
+
106
+ const render = (args: StoryArgs) => ({
107
+ components: { InputCheckboxRadioField },
63
108
  setup() {
64
109
  const modelValue = ref<boolean | string>(false);
65
110
  const componentArgs = computed(() => {
@@ -70,24 +115,52 @@ const Template: StoryFn<InputCheckboxRadioFieldStoryArgs> = (args) => ({
70
115
  return { args, componentArgs, tokenStyles, modelValue };
71
116
  },
72
117
  template: `
73
- <div style="margin: 36px; max-width: 480px;" :style="tokenStyles">
74
- <StorybookComponent v-bind="componentArgs" v-model="modelValue">
118
+ <div :style="tokenStyles">
119
+ ${parentNote}
120
+ <InputCheckboxRadioField v-bind="componentArgs" v-model="modelValue">
75
121
  <template v-if="args.useLabelSlot" #labelContent>
76
122
  I agree to the&nbsp;<a href="#terms">terms and conditions</a>
77
123
  </template>
78
- </StorybookComponent>
124
+ </InputCheckboxRadioField>
79
125
  <p style="margin-top: 2rem; font-family: monospace;">modelValue: {{ JSON.stringify(modelValue) }}</p>
80
126
  </div>
81
127
  `,
82
128
  });
83
129
 
84
- export const Checkbox = Template.bind({});
130
+ export const Checkbox: Story = { render };
131
+
132
+ export const RichLabel: Story = {
133
+ args: { useLabelSlot: true },
134
+ render,
135
+ parameters: { docs: { description: { story: "The `labelContent` slot replaces the `label` prop, here with a link." } } },
136
+ };
85
137
 
86
- export const RichLabel = Template.bind({});
87
- RichLabel.args = { useLabelSlot: true };
138
+ export const Underlined: Story = {
139
+ args: { inputVariant: "underlined" },
140
+ render,
141
+ parameters: { docs: { description: { story: "`inputVariant=\"underlined\"` squares off the checkbox." } } },
142
+ };
88
143
 
89
- export const Underlined = Template.bind({});
90
- Underlined.args = { inputVariant: "underlined" };
144
+ export const ErrorState: Story = {
145
+ args: { fieldHasError: true, theme: "error" },
146
+ render,
147
+ parameters: { docs: { description: { story: "`fieldHasError` sets `data-invalid` on the label and `aria-invalid` on the input." } } },
148
+ };
91
149
 
92
- export const ErrorState = Template.bind({});
93
- ErrorState.args = { fieldHasError: true, theme: "error" };
150
+ export const StressTest: Story = {
151
+ name: "Stress Test (Worst-Case Data)",
152
+ args: {
153
+ label:
154
+ "💇‍♀️ Datenschutzgrundverordnungseinwilligungsbestätigungserklärung https://example.com/privacy/policy/with/a/very/long/unbroken/path <b>not bold</b> قص الشعر",
155
+ },
156
+ render,
157
+ parameters: {
158
+ initialCanvas: "mobileCanvas",
159
+ docs: {
160
+ description: {
161
+ story:
162
+ "A label with emoji at the start, a long unbroken German word and URL, HTML-like text and right-to-left text. Check at every canvas width: the label wraps inside the dashed outline, the control keeps its size instead of being squashed, markup renders as text, and clicking anywhere on the label toggles the control. Also try an empty label and a single character, and switch `type` to `radio`.",
163
+ },
164
+ },
165
+ },
166
+ };
@@ -1,4 +1,5 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
+ import type { VueWrapper } from "@vue/test-utils";
2
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
4
  import InputCheckboxRadio from "../InputCheckboxRadio.vue";
4
5
 
@@ -11,12 +12,22 @@ const defaultProps = {
11
12
  modelValue: "unchecked",
12
13
  };
13
14
 
14
- const createWrapper = (props = {}, slots = {}) =>
15
- mountSuspended(InputCheckboxRadio, {
15
+ const mounted: VueWrapper[] = [];
16
+
17
+ const createWrapper = async (props = {}, slots = {}) => {
18
+ const wrapper = await mountSuspended(InputCheckboxRadio, {
16
19
  attachTo: document.body,
17
20
  props: { ...defaultProps, ...props },
18
21
  slots,
19
22
  });
23
+ mounted.push(wrapper);
24
+ return wrapper;
25
+ };
26
+
27
+ afterEach(() => {
28
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
29
+ vi.restoreAllMocks();
30
+ });
20
31
 
21
32
  const inputOf = (wrapper: Awaited<ReturnType<typeof createWrapper>>) => wrapper.find("input").element as HTMLInputElement;
22
33
 
@@ -1,4 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
+ import { defineComponent } from "vue";
3
+ import type { VueWrapper } from "@vue/test-utils";
2
4
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
5
  import InputCheckboxRadioButton from "../InputCheckboxRadioButton.vue";
4
6
 
@@ -12,12 +14,22 @@ const defaultProps = {
12
14
  multipleOptions: true,
13
15
  };
14
16
 
15
- const createWrapper = (props = {}, slots = {}) =>
16
- mountSuspended(InputCheckboxRadioButton, {
17
+ const mounted: VueWrapper[] = [];
18
+
19
+ const createWrapper = async (props = {}, slots = {}) => {
20
+ const wrapper = await mountSuspended(InputCheckboxRadioButton, {
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("InputCheckboxRadioButton", () => {
23
35
  it("renders a label linked to the inner control by id", async () => {
@@ -27,6 +39,29 @@ describe("InputCheckboxRadioButton", () => {
27
39
  expect(wrapper.find("input").attributes("id")).toBe("service-balayage");
28
40
  });
29
41
 
42
+ it("generates a space-free id linking label and control when no id is passed", async () => {
43
+ const wrapper = await createWrapper({ id: undefined });
44
+ const id = wrapper.find("input").attributes("id");
45
+
46
+ expect(id).toBeTruthy();
47
+ expect(id).not.toMatch(/\s/);
48
+ expect(wrapper.find("label.input-checkbox-radio-button").attributes("for")).toBe(id);
49
+ });
50
+
51
+ it("generates a different id for each instance", async () => {
52
+ const Host = defineComponent({
53
+ components: { InputCheckboxRadioButton },
54
+ setup: () => ({ props: { ...defaultProps, id: undefined } }),
55
+ template: `<div><InputCheckboxRadioButton v-bind="props" /><InputCheckboxRadioButton v-bind="props" /></div>`,
56
+ });
57
+ const host = await mountSuspended(Host);
58
+ mounted.push(host);
59
+ const ids = host.findAll("input").map((input) => input.attributes("id"));
60
+
61
+ expect(ids).toHaveLength(2);
62
+ expect(ids[0]).not.toBe(ids[1]);
63
+ });
64
+
30
65
  it("renders the label prop, or the labelContent slot in its place", async () => {
31
66
  const plain = await createWrapper();
32
67
  expect(plain.find(".input-checkbox-radio-button-label").text()).toBe("Balayage");