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.
- package/.claude/rules/consumer/ids-from-useid.md +4 -2
- package/.claude/skills/component-compliance-checklist.md +14 -3
- package/.claude/skills/component-inline-action-button.md +5 -4
- package/.claude/skills/component-naming.md +2 -1
- package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
- package/.claude/skills/components/alert-masked-content.md +3 -3
- package/.claude/skills/components/container-glow.md +1 -1
- package/.claude/skills/components/display-theme-switch.md +8 -8
- package/.claude/skills/components/form-field.md +15 -0
- package/.claude/skills/components/form-fieldset.md +18 -2
- package/.claude/skills/components/input-button.md +26 -7
- package/.claude/skills/components/input-checkbox-radio.md +2 -0
- package/.claude/skills/components/input-description.md +5 -0
- package/.claude/skills/components/input-error.md +9 -2
- package/.claude/skills/components/input-label.md +8 -2
- package/.claude/skills/components/input-number.md +2 -0
- package/.claude/skills/components/input-otp.md +2 -0
- package/.claude/skills/components/input-range.md +2 -0
- package/.claude/skills/components/input-select.md +2 -0
- package/.claude/skills/components/input-text.md +2 -0
- package/.claude/skills/components/input-textarea.md +2 -0
- package/.claude/skills/components/marquee-scroller.md +51 -14
- package/.claude/skills/components/pending-effect.md +5 -4
- package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
- package/.claude/skills/components/services-card.md +1 -1
- package/.claude/skills/components/tabbed-content.md +1 -1
- package/.claude/skills/components/toggle-switch.md +2 -0
- package/.claude/skills/index.md +5 -5
- package/.claude/skills/storybook-add-story.md +3 -1
- package/.claude/skills/theming-component-token-pattern.md +24 -0
- package/.claude/skills/theming-form-geometry-tokens.md +1 -1
- package/.claude/skills/theming-partial-override.md +1 -1
- package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
- 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/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
- package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
- package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
- package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
- package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
- package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
- package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
- 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/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
- package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
- package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
- package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
- package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
- package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
- package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
- package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
- package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
- package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
- package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
- package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
- package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
- package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
- package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/form-errors/InputError.vue +43 -37
- package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
- package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
- 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/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
- package/app/components/05.forms/input-button/InputButton.vue +52 -100
- package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
- package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
- package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
- package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
- package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
- package/app/components/05.forms/input-description/InputDescription.vue +2 -1
- package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
- package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/input-label/InputLabel.vue +1 -0
- package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
- package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-number/InputNumber.vue +4 -5
- package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
- package/app/components/05.forms/input-range/InputRange.vue +1 -1
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-select/InputSelect.vue +14 -5
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-text/InputText.vue +4 -4
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
- package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
- package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
- package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
- package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
- package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
- package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
- package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
- package/app/types/components/index.ts +1 -1
- package/package.json +1 -1
- package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
- package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
- package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
- package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
- package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
- package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
- /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
|
-
|
|
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");
|