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.
- package/.claude/skills/component-compliance-checklist.md +5 -0
- package/.claude/skills/components/form-field.md +15 -0
- package/.claude/skills/components/form-fieldset.md +18 -2
- package/.claude/skills/components/marquee-scroller.md +51 -14
- package/.vscode/srcdev-component-form-field.code-snippets +7 -6
- package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
- package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
- package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
- package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
- package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
- package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
- package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
- package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
- package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
|
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");
|