srcdev-nuxt-components 9.6.11 → 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.
@@ -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");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.6.11",
4
+ "version": "9.6.12",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",