srcdev-nuxt-components 9.6.7 → 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 (58) 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/index.md +5 -5
  14. package/.claude/skills/testing-add-unit-test.md +17 -0
  15. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  16. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  17. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  18. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  19. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  20. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  21. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  22. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  23. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  24. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  25. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  26. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  27. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  28. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  29. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  30. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  31. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  32. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  33. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  34. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +2 -2
  35. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +2 -2
  36. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +42 -2
  37. package/app/components/05.forms/input-number/InputNumber.vue +37 -25
  38. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  39. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  40. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  41. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  42. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  43. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  44. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  45. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  46. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  47. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  48. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  49. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  50. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  51. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  52. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  53. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  54. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +5 -5
  55. package/package.json +1 -1
  56. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  57. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  58. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
@@ -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);
@@ -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
+ };