srcdev-nuxt-components 9.6.10 → 9.6.12

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 (125) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +14 -3
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +2 -1
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/form-field.md +15 -0
  10. package/.claude/skills/components/form-fieldset.md +18 -2
  11. package/.claude/skills/components/input-button.md +26 -7
  12. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  13. package/.claude/skills/components/input-description.md +5 -0
  14. package/.claude/skills/components/input-error.md +9 -2
  15. package/.claude/skills/components/input-label.md +8 -2
  16. package/.claude/skills/components/input-number.md +2 -0
  17. package/.claude/skills/components/input-otp.md +2 -0
  18. package/.claude/skills/components/input-range.md +2 -0
  19. package/.claude/skills/components/input-select.md +2 -0
  20. package/.claude/skills/components/input-text.md +2 -0
  21. package/.claude/skills/components/input-textarea.md +2 -0
  22. package/.claude/skills/components/marquee-scroller.md +51 -14
  23. package/.claude/skills/components/pending-effect.md +5 -4
  24. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  25. package/.claude/skills/components/services-card.md +1 -1
  26. package/.claude/skills/components/tabbed-content.md +1 -1
  27. package/.claude/skills/components/toggle-switch.md +2 -0
  28. package/.claude/skills/index.md +5 -5
  29. package/.claude/skills/storybook-add-story.md +3 -1
  30. package/.claude/skills/theming-component-token-pattern.md +24 -0
  31. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  32. package/.claude/skills/theming-partial-override.md +1 -1
  33. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  34. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  35. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  36. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  37. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  38. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  39. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  40. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  41. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  42. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  43. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  44. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  45. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  46. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  47. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  48. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  49. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  50. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  51. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  52. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  53. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  54. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  55. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  56. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  57. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  58. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  59. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  60. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  61. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  62. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  63. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  64. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  65. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  66. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  67. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  68. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  69. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  70. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  71. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  72. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  73. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  74. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  75. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  76. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  77. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  78. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  79. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  80. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  81. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  82. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  83. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  84. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  85. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  86. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  87. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  88. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  89. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  90. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  91. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  92. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  93. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  94. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  95. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  96. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  97. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  98. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  99. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  100. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  101. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  102. package/app/components/05.forms/input-text/InputText.vue +4 -4
  103. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  104. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  105. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  106. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  107. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  108. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  109. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  110. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  111. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  112. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  113. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  114. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  115. package/app/types/components/index.ts +1 -1
  116. package/package.json +1 -1
  117. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  118. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  119. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  120. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  121. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  122. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  123. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  124. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  125. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -1,5 +1,8 @@
1
+ import { ref } from "vue";
1
2
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
3
  import StorybookComponent from "../InputError.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { MediaCanvas } from "~/types/components";
3
6
 
4
7
  interface InputErrorStoryArgs {
5
8
  id: string;
@@ -44,15 +47,52 @@ export default {
44
47
  icon: "radix-icons:circle-backslash",
45
48
  styleClassPassthrough: "",
46
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
+ "The error strip every `*Field` wrapper renders under its control, in grid row 2. `error-message` takes a string or an array (rendered as a list), `show-error` opens it, `is-detached` chooses between tucked under the control and sitting below it with a gap, and `input-variant` matches the control's variant. The control in these stories is a stand-in; see [InputTextField](?path=/story/components-forms-input-text-inputtextfield--with-error) (attached) and [MultipleCheckboxes](?path=/story/components-forms-input-checkbox-multiplecheckboxes--error-state) (detached) for it in real use.",
73
+ },
74
+ },
75
+ },
47
76
  } as Meta<InputErrorStoryArgs>;
48
77
 
78
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
79
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
80
+ const fieldNote = `
81
+ <p style="${storyNoteStyle}">
82
+ Every field wrapper renders this strip for you. See it attached under a real control in the
83
+ <a href="/?path=/story/components-forms-input-text-inputtextfield--with-error" target="_top">InputTextField</a>
84
+ story, and detached under a group in the
85
+ <a href="/?path=/story/components-forms-input-checkbox-multiplecheckboxes--error-state" target="_top">MultipleCheckboxes</a>
86
+ story.
87
+ </p>
88
+ `;
89
+
49
90
  const demoStyles = `
50
91
  <component is="style">
51
92
  .input-error-story-field {
52
93
  display: grid;
53
94
  grid-template-rows: auto auto;
54
95
  max-width: 48rem;
55
- margin: 3.6rem;
56
96
  }
57
97
  .input-error-story-control {
58
98
  grid-row: 1;
@@ -84,6 +124,7 @@ const Template: StoryFn<InputErrorStoryArgs> = (args) => ({
84
124
  template: `
85
125
  <div>
86
126
  ${demoStyles}
127
+ ${fieldNote}
87
128
  <div
88
129
  class="input-error-story-field"
89
130
  :data-invalid="args.showError ? '' : undefined"
@@ -105,21 +146,58 @@ const Template: StoryFn<InputErrorStoryArgs> = (args) => ({
105
146
  });
106
147
 
107
148
  export const Attached = Template.bind({});
149
+ Attached.parameters = {
150
+ docs: { description: { story: "The default: tucked under the control's bottom edge, sharing its border." } },
151
+ };
108
152
 
109
153
  export const Detached = Template.bind({});
110
154
  Detached.args = { isDetached: true };
155
+ Detached.parameters = {
156
+ docs: { description: { story: "Sits below the control with a gap (`--input-error-detached-offset`) and fully rounded corners, as used under checkbox and radio groups." } },
157
+ };
111
158
 
112
159
  export const MultipleMessages = Template.bind({});
113
160
  MultipleMessages.args = {
114
161
  isDetached: true,
115
162
  errorMessage: ["Password must be at least 12 characters", "Password must contain a number"],
116
163
  };
164
+ MultipleMessages.parameters = {
165
+ docs: { description: { story: "An array `error-message` renders as a list, spaced by `--input-error-list-gap`." } },
166
+ };
117
167
 
118
168
  export const Underlined = Template.bind({});
119
169
  Underlined.args = { inputVariant: "underlined" };
170
+ Underlined.parameters = {
171
+ docs: { description: { story: "Square corners, and no outline while attached, to match an underlined control." } },
172
+ };
120
173
 
121
174
  export const Hidden = Template.bind({});
122
175
  Hidden.args = { showError: false };
123
176
  Hidden.parameters = {
124
177
  docs: { description: { story: "Toggle `showError` to see the open/close animation." } },
125
178
  };
179
+
180
+ export const StressTest = Template.bind({});
181
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
182
+ StressTest.args = {
183
+ isDetached: true,
184
+ errorMessage: [
185
+ "DieseEingabeistleiderungültigbitteüberprüfenSiedieSchreibweiseundversuchenSieeserneut",
186
+ "https://example.com/a/very/long/unbroken/url/that/should/wrap/inside/the/strip?with=query&and=more",
187
+ "<b>not bold</b> <script>alert(1)</script>",
188
+ "⚠️ Bitte geben Sie eine gültige E-Mail-Adresse ein. Wir verwenden Ihre Adresse ausschließlich für Rückfragen zu Ihrer Bestellung bei Alex Morgan in Northtown.",
189
+ "يرجى إدخال عنوان بريد إلكتروني صالح",
190
+ "x",
191
+ "",
192
+ ],
193
+ };
194
+ StressTest.parameters = {
195
+ initialCanvas: "mobileCanvas",
196
+ docs: {
197
+ story: { inline: false, height: "720px" },
198
+ description: {
199
+ story:
200
+ "Long unbroken German, a long URL, HTML-like text, emoji, right-to-left text, a single character and an empty string in one array. Check at every canvas width: every message wraps inside the strip (no horizontal overflow past the dashed outline), the icon keeps its size, markup renders as text, and the empty item takes no visible space. Switch `errorMessage` to a single long string to check the non-list path too. Error text is never line-clamped: a truncated validation message can't be acted on.",
201
+ },
202
+ },
203
+ };
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import type { VueWrapper } from "@vue/test-utils";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
4
  import { nextTick } from "vue";
@@ -22,6 +22,11 @@ const wrapperFactory = (propsData = {}) => {
22
22
  };
23
23
 
24
24
  describe("InputError", () => {
25
+ afterEach(() => {
26
+ wrapper?.unmount();
27
+ vi.restoreAllMocks();
28
+ });
29
+
25
30
  it("renders the root with id, testid, error theme and passthrough classes", async () => {
26
31
  wrapper = await wrapperFactory();
27
32
  const root = wrapper.find(".input-error-message");
@@ -92,6 +97,29 @@ describe("InputError", () => {
92
97
  expect(items[1]!.text()).toBe("Hello World 2");
93
98
  });
94
99
 
100
+ it("keeps list semantics on the unstyled list", async () => {
101
+ wrapper = await wrapperFactory({ errorMessage: ["Hello World", "Hello World 2"] });
102
+
103
+ expect(wrapper.find(".input-error-message-list").attributes("role")).toBe("list");
104
+ });
105
+
106
+ it("drops blank entries from an array of messages", async () => {
107
+ wrapper = await wrapperFactory({ errorMessage: ["First", "", " ", "Second"] });
108
+ const items = wrapper.findAll(".input-error-message-list-item");
109
+
110
+ expect(items).toHaveLength(2);
111
+ expect(items[0]!.text()).toBe("First");
112
+ expect(items[1]!.text()).toBe("Second");
113
+ });
114
+
115
+ it("renders HTML-like message text as text", async () => {
116
+ wrapper = await wrapperFactory({ errorMessage: "<b>not bold</b>" });
117
+ const message = wrapper.find(".input-error-message-single");
118
+
119
+ expect(message.find("b").exists()).toBe(false);
120
+ expect(message.text()).toBe("<b>not bold</b>");
121
+ });
122
+
95
123
  it("renders a decorative icon that can be overridden", async () => {
96
124
  wrapper = await wrapperFactory();
97
125
  const icon = wrapper.find(".input-error-message-icon");
@@ -89,3 +89,10 @@ Or scoped by a page/section wrapper class:
89
89
 
90
90
  `:style-class-passthrough` (string or string array) adds classes to the root `.form-field` element.
91
91
 
92
+ ---
93
+
94
+ ## Notes
95
+
96
+ - FormField sets no `overflow-wrap` or `min-inline-size` on its slot content. The `*Field`
97
+ components wrap their own long copy; plain text you slot in directly needs its own wrapping rule.
98
+
@@ -1,5 +1,9 @@
1
+ import { ref } from "vue";
1
2
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
3
  import StorybookComponent from "../FormField.vue";
4
+ import InputTextField from "../../input-text/InputTextField.vue";
5
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
6
+ import type { MediaCanvas } from "~/types/components";
3
7
 
4
8
  interface FormFieldStoryArgs {
5
9
  width: "narrow" | "medium" | "wide";
@@ -27,6 +31,32 @@ export default {
27
31
  fieldHasError: false,
28
32
  styleClassPassthrough: "",
29
33
  },
34
+ decorators: [
35
+ (story, context) => ({
36
+ components: { story, CanvasSwitcher },
37
+ setup() {
38
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
39
+ return { canvasName };
40
+ },
41
+ template: `
42
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
43
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
44
+ </div>
45
+ <div :class="canvasName" style="margin-inline: auto;">
46
+ <story />
47
+ </div>
48
+ `,
49
+ }),
50
+ ],
51
+ parameters: {
52
+ layout: "fullscreen",
53
+ docs: {
54
+ description: {
55
+ component:
56
+ "Layout-only wrapper for one form control. `width` (`narrow`/`medium`/`wide`) caps its width, `has-gutter` insets it from the container edges, and `field-has-error` adds `data-invalid`, which switches every descendant to the error theme. Put an `*Field` component in the default slot; it renders no label, input or error text itself. `style-class-passthrough` lands on the `.form-field` root.",
57
+ },
58
+ },
59
+ },
30
60
  } as Meta<FormFieldStoryArgs>;
31
61
 
32
62
  const demoStyles = `
@@ -46,9 +76,23 @@ const demoStyles = `
46
76
  padding: 1.2rem;
47
77
  font-size: 1.4rem;
48
78
  }
79
+ .form-field-story-note {
80
+ font-size: 1.4rem;
81
+ margin: 1.6rem;
82
+ }
49
83
  </component>
50
84
  `;
51
85
 
86
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
87
+ const usageNote = `
88
+ <p class="form-field-story-note">
89
+ Dashed outline: container. Solid outline and tint: the FormField. For FormField wrapping real
90
+ fields inside a form, open the
91
+ <a href="/?path=/story/patterns-contact-form--default" target="_top">Contact Form</a> pattern or the
92
+ <a href="/?path=/story/components-forms-form-wrapper-formwrapper--default" target="_top">FormWrapper</a> story.
93
+ </p>
94
+ `;
95
+
52
96
  const Template: StoryFn<FormFieldStoryArgs> = (args) => ({
53
97
  components: { StorybookComponent },
54
98
  setup() {
@@ -57,7 +101,7 @@ const Template: StoryFn<FormFieldStoryArgs> = (args) => ({
57
101
  template: `
58
102
  <div>
59
103
  ${demoStyles}
60
- <p style="font-size: 1.4rem; margin: 1.6rem;">Dashed outline: container. Solid outline and tint: the FormField.</p>
104
+ ${usageNote}
61
105
  <div class="form-field-story-frame">
62
106
  <StorybookComponent
63
107
  :width="args.width"
@@ -73,6 +117,9 @@ const Template: StoryFn<FormFieldStoryArgs> = (args) => ({
73
117
  });
74
118
 
75
119
  export const Default = Template.bind({});
120
+ Default.parameters = {
121
+ docs: { description: { story: "One FormField around a placeholder control. Use the Controls panel to try each width, the gutter and the error state." } },
122
+ };
76
123
 
77
124
  const WidthsTemplate: StoryFn<FormFieldStoryArgs> = (args) => ({
78
125
  components: { StorybookComponent },
@@ -82,6 +129,7 @@ const WidthsTemplate: StoryFn<FormFieldStoryArgs> = (args) => ({
82
129
  template: `
83
130
  <div>
84
131
  ${demoStyles}
132
+ ${usageNote}
85
133
  <div class="form-field-story-frame">
86
134
  <StorybookComponent
87
135
  v-for="w in widths"
@@ -98,7 +146,73 @@ const WidthsTemplate: StoryFn<FormFieldStoryArgs> = (args) => ({
98
146
  });
99
147
 
100
148
  export const AllWidths = WidthsTemplate.bind({});
101
- AllWidths.parameters = { controls: { include: ["hasGutter", "fieldHasError"] } };
149
+ AllWidths.parameters = {
150
+ controls: { include: ["hasGutter", "fieldHasError"] },
151
+ docs: {
152
+ description: {
153
+ story: "The three widths stacked. Each caps at its `--form-field-max-width-*` token and shrinks to the container (minus the gutter) below that.",
154
+ },
155
+ },
156
+ };
102
157
 
103
158
  export const NoGutter = Template.bind({});
104
159
  NoGutter.args = { width: "wide", hasGutter: false };
160
+ NoGutter.parameters = {
161
+ docs: { description: { story: "`has-gutter` off: the field runs edge to edge of its container. Use this when the parent already pads its content." } },
162
+ };
163
+
164
+ const stressLabel =
165
+ "📧 Elektronischepostadressefürdiebenachrichtigungüberbestellungen https://example.com/a/very/long/unbroken/url <b>not bold</b> البريد";
166
+ const stressValue = "alex.morgan.with.a.very.long.unbroken.address@subdomain.northtown-example.co.uk";
167
+ const stressError = "DieseadresseistleiderungültigbitteüberprüfenSiedieSchreibweise";
168
+
169
+ const StressTemplate: StoryFn<FormFieldStoryArgs> = (args) => ({
170
+ components: { StorybookComponent, InputTextField },
171
+ setup() {
172
+ const fields = ref([
173
+ { width: "narrow", hasGutter: true, hasError: true, value: stressValue },
174
+ { width: "medium", hasGutter: true, hasError: false, value: "" },
175
+ { width: "wide", hasGutter: false, hasError: true, value: "x" },
176
+ ]);
177
+ return { args, fields, stressLabel, stressError };
178
+ },
179
+ template: `
180
+ <div>
181
+ ${demoStyles}
182
+ ${usageNote}
183
+ <div class="form-field-story-frame">
184
+ <StorybookComponent
185
+ v-for="(field, index) in fields"
186
+ :key="index"
187
+ :width="field.width"
188
+ :has-gutter="field.hasGutter"
189
+ :field-has-error="field.hasError"
190
+ :style-class-passthrough="args.styleClassPassthrough"
191
+ >
192
+ <InputTextField
193
+ v-model="field.value"
194
+ :name="'stress-' + index"
195
+ type="email"
196
+ :label="stressLabel"
197
+ :error-message="field.hasError ? stressError : ''"
198
+ :field-has-error="field.hasError"
199
+ required
200
+ ></InputTextField>
201
+ </StorybookComponent>
202
+ </div>
203
+ </div>
204
+ `,
205
+ });
206
+
207
+ export const StressTest = StressTemplate.bind({});
208
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
209
+ StressTest.parameters = {
210
+ initialCanvas: "mobileCanvas",
211
+ controls: { include: ["styleClassPassthrough"] },
212
+ docs: {
213
+ description: {
214
+ story:
215
+ "Each width, with and without the gutter and error state, around an `InputTextField` carrying long unbroken German, a URL, emoji, HTML-like and right-to-left label copy, a long unbroken value and an unbroken error. Check at every canvas width: every FormField stays inside the dashed container, the gutter stays visible on both sides, and the error theme reaches the field inside. Wrapping the slot content is the slotted control's job (FormField doesn't change `overflow-wrap`), so plain text you slot in needs its own.",
216
+ },
217
+ },
218
+ };
@@ -35,11 +35,14 @@ behaviour; setting the token now works as expected.
35
35
 
36
36
  | Selector | When |
37
37
  |---|---|
38
- | `.form-fieldset[aria-invalid="true"]` | `field-has-error` is `true` |
38
+ | `.form-fieldset[aria-invalid="true"]` | `field-has-error` is `true` (the attribute is absent otherwise) |
39
39
  | `.form-fieldset[role="radiogroup"]` | Rendered by `MultipleRadiobuttons` (`group-role="radiogroup"`) |
40
40
 
41
41
  > **Changed 2026-09-26**: the fieldset no longer adds a bare `.error` class in the error state.
42
42
  > Target `[aria-invalid="true"]` instead.
43
+ >
44
+ > **Changed 2026-10-17**: a valid fieldset no longer renders `aria-invalid="false"`. Replace any
45
+ > `[aria-invalid="false"]` selector with `:not([aria-invalid])`.
43
46
 
44
47
  The wrapping components add their own class too (`.single-checkbox-fieldset`,
45
48
  `.multiple-checkboxes-fieldset`, `.multiple-radiobuttons-fieldset`), so you can scope overrides
@@ -86,3 +89,10 @@ Set the tokens above on an element you own (a page or section class, or a class
86
89
 
87
90
  `:style-class-passthrough` (string or string array) adds classes to the `<fieldset>`.
88
91
 
92
+ ---
93
+
94
+ ## Notes
95
+
96
+ - The fieldset sets `min-inline-size: 0`, so a native fieldset's min-content width can't push it
97
+ past a narrow column, and the legend wraps long unbroken copy (`overflow-wrap: anywhere`).
98
+ Wrapping the slotted controls is the slot content's job.
@@ -3,11 +3,11 @@
3
3
  :id="id"
4
4
  :role="groupRole === 'radiogroup' ? 'radiogroup' : undefined"
5
5
  :aria-required="groupRole === 'radiogroup' && required ? 'true' : undefined"
6
- :aria-invalid="fieldHasError"
6
+ :aria-invalid="fieldHasError ? 'true' : undefined"
7
7
  :name="name"
8
8
  class="form-fieldset"
9
9
  :class="elementClasses"
10
- :data-testid="dataTestid"
10
+ :data-testid="dataTestid || undefined"
11
11
  >
12
12
  <legend v-if="legend || slots.legend" class="form-fieldset-legend">
13
13
  <slot name="legend">{{ legend }}</slot>
@@ -54,6 +54,7 @@ watch(
54
54
  <style lang="css">
55
55
  @layer components {
56
56
  .form-fieldset {
57
+ min-inline-size: 0;
57
58
  margin-block: var(--form-fieldset-margin-block, 0);
58
59
  margin-inline: var(--form-fieldset-margin-inline, 0);
59
60
  padding-block: var(--form-fieldset-padding-block, 0);
@@ -65,6 +66,7 @@ watch(
65
66
 
66
67
  .form-fieldset-legend {
67
68
  color: var(--form-fieldset-legend-color, inherit);
69
+ overflow-wrap: anywhere;
68
70
  margin-block: var(--form-fieldset-legend-margin-block, 0.8rem);
69
71
  margin-inline: var(--form-fieldset-legend-margin-inline, 0);
70
72
  font-size: var(--form-fieldset-legend-font-size, var(--step-5));
@@ -1,5 +1,8 @@
1
+ import { ref } from "vue";
1
2
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
3
  import StorybookComponent from "../FormFieldset.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { MediaCanvas } from "~/types/components";
3
6
 
4
7
  interface FormFieldsetStoryArgs {
5
8
  id: string;
@@ -41,15 +44,53 @@ export default {
41
44
  fieldHasError: false,
42
45
  styleClassPassthrough: "",
43
46
  },
47
+ decorators: [
48
+ (story, context) => ({
49
+ components: { story, CanvasSwitcher },
50
+ setup() {
51
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
52
+ return { canvasName };
53
+ },
54
+ template: `
55
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
56
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
57
+ </div>
58
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
59
+ <story />
60
+ </div>
61
+ `,
62
+ }),
63
+ ],
64
+ parameters: {
65
+ layout: "fullscreen",
66
+ docs: {
67
+ description: {
68
+ component:
69
+ "Shared `<fieldset>` + `<legend>` wrapper for a group of controls. `legend` (or the `legend` slot) names the group, the `content` slot holds the controls, `group-role` switches between the native `group` and `radiogroup`, and `required`/`field-has-error` set `aria-required` (radiogroup only) and `aria-invalid`. Mostly a building block: `SingleCheckbox`, `MultipleCheckboxes` and `MultipleRadiobuttons` render it.",
70
+ },
71
+ },
72
+ },
44
73
  } as Meta<FormFieldsetStoryArgs>;
45
74
 
75
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
76
+ const parentNote = `
77
+ <p style="font-size: 1.3rem; margin: 0 0 1.6rem;">
78
+ Rendered by
79
+ <a href="/?path=/story/components-forms-input-checkbox-singlecheckbox--default" target="_top">SingleCheckbox</a>,
80
+ <a href="/?path=/story/components-forms-input-checkbox-multiplecheckboxes--default" target="_top">MultipleCheckboxes</a> and
81
+ <a href="/?path=/story/components-forms-input-radio-multipleradiobuttons--default" target="_top">MultipleRadiobuttons</a>,
82
+ which add styled options, a description and an error message. Inspect the fieldset to see role and aria-* output.
83
+ </p>
84
+ `;
85
+
46
86
  const Template: StoryFn<FormFieldsetStoryArgs> = (args) => ({
47
87
  components: { StorybookComponent },
48
88
  setup() {
49
89
  return { args };
50
90
  },
51
91
  template: `
52
- <div style="margin: 3.6rem; max-width: 48rem;">
92
+ <div>
93
+ ${parentNote}
53
94
  <StorybookComponent
54
95
  :id="args.id"
55
96
  :name="args.name"
@@ -68,17 +109,40 @@ const Template: StoryFn<FormFieldsetStoryArgs> = (args) => ({
68
109
  </label>
69
110
  </template>
70
111
  </StorybookComponent>
71
- <p style="font-size: 1.3rem; margin-top: 1.6rem;">
72
- Rendered by SingleCheckbox, MultipleCheckboxes and MultipleRadiobuttons. Inspect the fieldset to see role and aria-* output.
73
- </p>
74
112
  </div>
75
113
  `,
76
114
  });
77
115
 
78
116
  export const Default = Template.bind({});
117
+ Default.parameters = {
118
+ docs: { description: { story: "A required radio group: `role=\"radiogroup\"` and `aria-required=\"true\"` on the fieldset." } },
119
+ };
79
120
 
80
121
  export const CheckboxGroup = Template.bind({});
81
122
  CheckboxGroup.args = { groupRole: "group", legend: "Interests", required: false };
123
+ CheckboxGroup.parameters = {
124
+ docs: { description: { story: "`group-role=\"group\"`: no `role` attribute (a fieldset is already a group) and never `aria-required`." } },
125
+ };
82
126
 
83
127
  export const WithError = Template.bind({});
84
128
  WithError.args = { fieldHasError: true };
129
+ WithError.parameters = {
130
+ docs: { description: { story: "`field-has-error` sets `aria-invalid=\"true\"`. With it off, the attribute is left out entirely." } },
131
+ };
132
+
133
+ export const StressTest = Template.bind({});
134
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
135
+ StressTest.args = {
136
+ legend:
137
+ "📞 Bevorzugtekontaktmethodefürrückfragenzuihrerbestellung https://example.com/a/very/long/unbroken/url <b>not bold</b> طريقة الاتصال",
138
+ fieldHasError: true,
139
+ };
140
+ StressTest.parameters = {
141
+ initialCanvas: "mobileCanvas",
142
+ docs: {
143
+ description: {
144
+ story:
145
+ "Long unbroken German, a URL, emoji, HTML-like and right-to-left copy in the legend, with the error state on. Check at every canvas width: the legend wraps inside the dashed outline, the fieldset never grows past its column (it sets `min-inline-size: 0`, so a native fieldset's min-content width can't push it out), and markup renders as text. Wrapping the slotted controls is the slot content's job.",
146
+ },
147
+ },
148
+ };
@@ -22,6 +22,11 @@ describe("FormFieldset", () => {
22
22
  expect(fieldset.attributes("data-testid")).toBe("fieldset-test");
23
23
  });
24
24
 
25
+ it("omits data-testid when none is given", async () => {
26
+ const wrapper = await mountSuspended(FormFieldset, { props: baseProps });
27
+ expect(wrapper.find("fieldset").attributes("data-testid")).toBeUndefined();
28
+ });
29
+
25
30
  it("renders the content slot", async () => {
26
31
  const wrapper = await mountSuspended(FormFieldset, {
27
32
  props: baseProps,
@@ -79,6 +84,11 @@ describe("FormFieldset", () => {
79
84
  expect(wrapper.find("fieldset").attributes("aria-invalid")).toBe("true");
80
85
  });
81
86
 
87
+ it("omits aria-invalid when fieldHasError is false", async () => {
88
+ const wrapper = await mountSuspended(FormFieldset, { props: baseProps });
89
+ expect(wrapper.find("fieldset").attributes("aria-invalid")).toBeUndefined();
90
+ });
91
+
82
92
  it("does not add a bare error class", async () => {
83
93
  const wrapper = await mountSuspended(FormFieldset, { props: { ...baseProps, fieldHasError: true } });
84
94
  expect(wrapper.find("fieldset").classes()).not.toContain("error");