srcdev-nuxt-components 9.6.11 → 9.6.13

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 (77) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +2 -1
  2. package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
  3. package/.claude/skills/component-compliance-checklist.md +24 -1
  4. package/.claude/skills/component-naming.md +1 -0
  5. package/.claude/skills/components/banner-video.md +24 -30
  6. package/.claude/skills/components/content-docs.md +18 -5
  7. package/.claude/skills/components/display-dialog.md +16 -6
  8. package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
  9. package/.claude/skills/components/display-tooltip.md +17 -6
  10. package/.claude/skills/components/form-field.md +15 -0
  11. package/.claude/skills/components/form-fieldset.md +18 -2
  12. package/.claude/skills/components/marquee-scroller.md +51 -14
  13. package/.claude/skills/components/skip-links.md +16 -0
  14. package/.claude/skills/composable-tooltips-guide.md +4 -4
  15. package/.claude/skills/index.md +6 -6
  16. package/.claude/skills/testing-add-unit-test.md +8 -0
  17. package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
  18. package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
  19. package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
  20. package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
  21. package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
  22. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  23. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  24. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  25. package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
  26. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  27. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  28. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  29. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  30. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  31. package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
  32. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
  33. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
  34. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
  35. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
  36. package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
  37. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
  38. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
  39. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
  40. package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
  41. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
  42. package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
  43. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
  44. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
  45. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
  46. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
  47. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
  48. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
  49. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
  50. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
  51. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
  52. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
  53. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
  54. package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
  55. package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
  56. package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
  57. package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
  58. package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
  59. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
  60. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
  61. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
  62. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  63. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  64. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  65. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  66. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  67. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  68. package/app/composables/tests/useTooltips.spec.ts +3 -3
  69. package/app/types/components/index.ts +1 -1
  70. package/package.json +1 -1
  71. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
  72. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
  73. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
  74. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
  75. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
  76. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
  77. /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
@@ -0,0 +1,254 @@
1
+ import { computed, ref } from "vue";
2
+ import DisplayTooltipRich from "../DisplayTooltipRich.vue";
3
+ import DisplayTooltip from "../../../01.atoms/display-tooltip/DisplayTooltip.vue";
4
+ import { useTooltipsGuide } from "~/composables/useTooltips";
5
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
6
+ import type { TooltipContentText } from "~/types/components";
7
+
8
+ type LineClamp = "none" | "1" | "2" | "3";
9
+
10
+ type StoryArgs = {
11
+ tooltipId: string;
12
+ contentText: TooltipContentText;
13
+ triggerAriaLabel: string;
14
+ triggerIcon: string;
15
+ closeButtonText: string;
16
+ closeButtonAriaLabel: string;
17
+ titleLineClamp: LineClamp;
18
+ bodyLineClamp: LineClamp;
19
+ actionLineClamp: LineClamp;
20
+ styleClassPassthrough?: string | string[];
21
+ };
22
+
23
+ const contentText: TooltipContentText = {
24
+ tooltipTitle: { tag: "h4", text: "Free delivery" },
25
+ tooltipContent: { tag: "p", text: "Orders over £50 qualify for free standard delivery." },
26
+ tooltipAction: { tag: "span", text: "See delivery policy" },
27
+ };
28
+
29
+ const lineClampControl = (element: string) => ({
30
+ control: { type: "select" as const },
31
+ options: ["none", "1", "2", "3"],
32
+ description: `Story-only CSS token, not a prop: sets \`--display-tooltip-rich-${element}-line-clamp\` on a wrapper (\`1\` is single-line ellipsis).`,
33
+ table: { category: "CSS tokens (story-only)" },
34
+ });
35
+
36
+ const meta: Meta<StoryArgs> = {
37
+ title: "Molecules/DisplayTooltipRich",
38
+ component: DisplayTooltipRich,
39
+ argTypes: {
40
+ tooltipId: {
41
+ control: "text",
42
+ description: "Base id for the popover, prefixed with `nuxt-tooltip-`. Auto-generated when omitted.",
43
+ table: { category: "Basic" },
44
+ },
45
+ triggerIcon: {
46
+ control: "text",
47
+ description: "Iconify name for the trigger button icon (DisplayTooltip's default when empty)",
48
+ table: { category: "Basic" },
49
+ },
50
+ triggerAriaLabel: {
51
+ control: "text",
52
+ description: "aria-label on the trigger button (override for localisation)",
53
+ table: { category: "Accessibility" },
54
+ },
55
+ closeButtonText: {
56
+ control: "text",
57
+ description: "Visible close button text (override for localisation)",
58
+ table: { category: "Content" },
59
+ },
60
+ closeButtonAriaLabel: {
61
+ control: "text",
62
+ description: "aria-label on the close button; keep the visible text inside it",
63
+ table: { category: "Accessibility" },
64
+ },
65
+ titleLineClamp: lineClampControl("title"),
66
+ bodyLineClamp: lineClampControl("body"),
67
+ actionLineClamp: lineClampControl("action"),
68
+ contentText: { table: { disable: true } },
69
+ styleClassPassthrough: { table: { disable: true } },
70
+ },
71
+ args: {
72
+ contentText,
73
+ triggerAriaLabel: "Toggle the popover",
74
+ closeButtonText: "Close",
75
+ closeButtonAriaLabel: "Close tool tip",
76
+ titleLineClamp: "none",
77
+ bodyLineClamp: "none",
78
+ actionLineClamp: "none",
79
+ },
80
+ parameters: {
81
+ docs: {
82
+ description: {
83
+ component:
84
+ "Material 3's rich tooltip, built on DisplayTooltip: a structured panel where `contentText` supplies an optional title, body and action line (each with its own tag), followed by a built-in close button (an `InputButton`) wired to the popover and to DisplayTooltip's `close()`. Props localise the trigger and close button (`triggerAriaLabel`, `triggerIcon`, `closeButtonText`, `closeButtonAriaLabel`); the `triggerContent` slot passes through. For free-form panel content use DisplayTooltip directly.",
85
+ },
86
+ },
87
+ },
88
+ };
89
+
90
+ export default meta;
91
+ type Story = StoryObj<StoryArgs>;
92
+
93
+ const clampSetup = (args: StoryArgs) => ({
94
+ componentArgs: computed(() => {
95
+ const { titleLineClamp: _t, bodyLineClamp: _b, actionLineClamp: _a, ...rest } = args;
96
+ return { ...rest, triggerIcon: rest.triggerIcon || undefined };
97
+ }),
98
+ clampStyle: computed(() => ({
99
+ "--display-tooltip-rich-title-line-clamp": args.titleLineClamp,
100
+ "--display-tooltip-rich-body-line-clamp": args.bodyLineClamp,
101
+ "--display-tooltip-rich-action-line-clamp": args.actionLineClamp,
102
+ })),
103
+ });
104
+
105
+ const renderWithClampTokens: Story["render"] = (args) => ({
106
+ components: { DisplayTooltipRich },
107
+ setup: () => clampSetup(args),
108
+ template: `<div style="padding: 8rem;" :style="clampStyle"><DisplayTooltipRich v-bind="componentArgs" /></div>`,
109
+ });
110
+
111
+ export const Default: Story = {
112
+ render: renderWithClampTokens,
113
+ };
114
+
115
+ export const TitleOnly: Story = {
116
+ args: {
117
+ contentText: { tooltipTitle: { tag: "h4", text: "Just a title" } },
118
+ },
119
+ render: renderWithClampTokens,
120
+ };
121
+
122
+ export const StressTest: Story = {
123
+ name: "Stress Test (Worst-Case Data)",
124
+ args: {
125
+ contentText: {
126
+ tooltipTitle: {
127
+ tag: "h4",
128
+ text: "😀 Kostenloselieferungfürallebestellungenübereinemmindestbestellwert <b>not bold</b> التوصيل المجاني",
129
+ },
130
+ tooltipContent: {
131
+ tag: "",
132
+ text: "https://example.invalid/a/very/long/unbroken/url/that/never/offers/a/break/opportunity/anywhere ".repeat(2) +
133
+ "Bestellungen über fünfzig Pfund qualifizieren sich für die kostenlose Standardlieferung innerhalb des Vereinigten Königreichs, ausgenommen sind Inseln und abgelegene Regionen.",
134
+ },
135
+ tooltipAction: { tag: "span", text: "Lieferrichtliniensowierückgabebedingungenansehen" },
136
+ },
137
+ triggerAriaLabel: "Weitereinformationenzurlieferunganzeigen 😀",
138
+ closeButtonText: "Schließenundzurückzurbestellübersicht",
139
+ closeButtonAriaLabel: "Schließenundzurückzurbestellübersicht",
140
+ titleLineClamp: "2",
141
+ bodyLineClamp: "none",
142
+ actionLineClamp: "1",
143
+ },
144
+ render: (args) => ({
145
+ components: { DisplayTooltipRich },
146
+ setup: () => clampSetup(args),
147
+ template: `
148
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.4rem;" :style="clampStyle">
149
+ <p style="margin: 0;">Every field at its worst, an empty body tag (falls back to <code>&lt;p&gt;</code>) and a long unbroken close label. The line-clamp controls apply to this tooltip.</p>
150
+ <div style="inline-size: 18rem; outline: 0.1rem dashed currentColor;">
151
+ <DisplayTooltipRich v-bind="componentArgs">
152
+ <template #triggerContent>
153
+ <span>Lieferkostenundversandbedingungen</span>
154
+ </template>
155
+ </DisplayTooltipRich>
156
+ </div>
157
+
158
+ <p style="margin: 0;">Empty <code>contentText</code>: the panel holds only the close button.</p>
159
+ <DisplayTooltipRich :content-text="{}"></DisplayTooltipRich>
160
+
161
+ <p style="margin: 0;">Single characters in every field.</p>
162
+ <DisplayTooltipRich
163
+ :content-text="{ tooltipTitle: { tag: 'h4', text: 'T' }, tooltipContent: { tag: 'p', text: 'B' }, tooltipAction: { tag: 'span', text: 'A' } }"
164
+ close-button-text="X"
165
+ close-button-aria-label="X"
166
+ ></DisplayTooltipRich>
167
+
168
+ <p style="margin: 0;">Right-to-left content at the right edge: the panel flips to the left of the trigger.</p>
169
+ <div style="display: flex; justify-content: flex-end;">
170
+ <DisplayTooltipRich
171
+ :content-text="{ tooltipTitle: { tag: 'h4', text: 'التوصيل المجاني' }, tooltipContent: { tag: 'p', text: 'الطلبات التي تزيد عن خمسين جنيهًا مؤهلة للتوصيل المجاني.' } }"
172
+ ></DisplayTooltipRich>
173
+ </div>
174
+ </div>
175
+ `,
176
+ }),
177
+ parameters: {
178
+ docs: {
179
+ story: { inline: false, height: "900px" },
180
+ description: {
181
+ story:
182
+ "Worst-case `contentText`. Open each tooltip and check: unbroken title, body, action and close-button text wraps inside the panel and the panel never gets wider than the viewport; HTML-like text renders as text; the title is capped at two lines and the action at one with an ellipsis (change them with the story-only line-clamp controls); the close button stays right-aligned at its natural width instead of stretching; an empty `contentText` leaves just the close button; a trigger at the right edge opens its panel to the left.",
183
+ },
184
+ },
185
+ },
186
+ };
187
+
188
+ export const GuidedTour: Story = {
189
+ parameters: {
190
+ docs: {
191
+ story: { inline: false, height: "500px" },
192
+ description: {
193
+ story:
194
+ "`useTooltipsGuide` walks a container's `[popover]` elements in DOM order, opening each and waiting for its close button before advancing. Mixes a `DisplayTooltipRich` step (its close button relabelled \"Next\" via `closeButtonText`) with a plain `DisplayTooltip` step. A hand-written close button must call the `tooltipContent` slot's `close()` as well as carrying `popovertargetaction=\"hide\"`: the attribute alone does nothing on Safari 16, which has no Popover API. The guide auto-starts 800ms after mount; use \"Run guide again\" to replay it.",
195
+ },
196
+ },
197
+ },
198
+ render: () => ({
199
+ components: { DisplayTooltipRich, DisplayTooltip },
200
+ setup() {
201
+ const guideContainer = ref<HTMLElement | null>(null);
202
+ const { isGuideRunning, restartGuide, currentTooltipIndex, totalPopovers } = useTooltipsGuide(guideContainer, {
203
+ autoStart: true,
204
+ startDelay: 800,
205
+ });
206
+
207
+ return { guideContainer, isGuideRunning, restartGuide, currentTooltipIndex, totalPopovers };
208
+ },
209
+ template: `
210
+ <div style="padding: 4rem; display: grid; gap: 2rem; max-width: 48rem;">
211
+ <div style="display: flex; align-items: center; gap: 1.2rem;">
212
+ <button type="button" @click="restartGuide" :disabled="isGuideRunning" style="padding: 0.6rem 1.2rem;">
213
+ {{ isGuideRunning ? 'Guide running…' : 'Run guide again' }}
214
+ </button>
215
+ <span v-if="isGuideRunning" style="font-size: 1.3rem; color: #666;">
216
+ Step {{ currentTooltipIndex + 1 }} of {{ totalPopovers }}: click Next (Close on the last step) to advance
217
+ </span>
218
+ </div>
219
+
220
+ <div ref="guideContainer" style="display: grid; gap: 2rem;">
221
+ <DisplayTooltipRich
222
+ close-button-text="Next"
223
+ close-button-aria-label="Next step"
224
+ :content-text="{
225
+ tooltipTitle: { tag: 'h3', text: 'Step 1' },
226
+ tooltipContent: { tag: 'p', text: 'This step uses DisplayTooltipRich: structured title/body/action content plus a built-in close button.' },
227
+ tooltipAction: { tag: 'p', text: 'Step 1 of 2' },
228
+ }"
229
+ >
230
+ <template #triggerContent>Trigger from DisplayTooltipRich</template>
231
+ </DisplayTooltipRich>
232
+
233
+ <DisplayTooltip tooltip-id="guided-tour-step-2">
234
+ <template #triggerContent>Trigger from plain DisplayTooltip</template>
235
+ <template #tooltipContent="{ close }">
236
+ <p>This step uses plain DisplayTooltip: you supply the close button yourself via the tooltipContent slot.</p>
237
+ <button
238
+ type="button"
239
+ popovertarget="guided-tour-step-2"
240
+ popovertargetaction="hide"
241
+ class="display-tooltip-close-button"
242
+ style="align-self: flex-end;"
243
+ aria-label="Close tool tip"
244
+ @click="close"
245
+ >
246
+ Close
247
+ </button>
248
+ </template>
249
+ </DisplayTooltip>
250
+ </div>
251
+ </div>
252
+ `,
253
+ }),
254
+ };
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import DisplayTooltipRich from "../DisplayTooltipRich.vue";
4
+ import type { TooltipContentText } from "~/types/components";
5
+
6
+ const contentText: TooltipContentText = {
7
+ tooltipTitle: { tag: "h4", text: "Title" },
8
+ tooltipContent: { tag: "p", text: "Body copy" },
9
+ tooltipAction: { tag: "span", text: "Learn more" },
10
+ };
11
+
12
+ const mounted: { unmount: () => void }[] = [];
13
+
14
+ const mountDefined = async (options: Parameters<typeof mountSuspended<typeof DisplayTooltipRich>>[1] = {}) => {
15
+ const wrapper = await mountSuspended(DisplayTooltipRich, options);
16
+ mounted.push(wrapper);
17
+ return wrapper;
18
+ };
19
+
20
+ describe("DisplayTooltipRich", () => {
21
+ afterEach(() => {
22
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ it("mounts without error", async () => {
27
+ const wrapper = await mountDefined();
28
+ expect(wrapper.vm).toBeTruthy();
29
+ });
30
+
31
+ it("renders correct HTML structure", async () => {
32
+ const wrapper = await mountDefined({ props: { contentText, tooltipId: "fixed" } });
33
+ expect(wrapper.html()).toMatchSnapshot();
34
+ });
35
+
36
+ it("renders the title, body, and action from contentText", async () => {
37
+ const wrapper = await mountDefined({ props: { contentText } });
38
+ expect(wrapper.find(".display-tooltip-rich-title").text()).toBe("Title");
39
+ expect(wrapper.find(".display-tooltip-rich-body").text()).toBe("Body copy");
40
+ expect(wrapper.find(".display-tooltip-rich-action").text()).toBe("Learn more");
41
+ });
42
+
43
+ it("renders each contentText field with its given tag", async () => {
44
+ const wrapper = await mountDefined({ props: { contentText } });
45
+ expect(wrapper.find(".display-tooltip-rich-title").element.tagName.toLowerCase()).toBe("h4");
46
+ expect(wrapper.find(".display-tooltip-rich-body").element.tagName.toLowerCase()).toBe("p");
47
+ expect(wrapper.find(".display-tooltip-rich-action").element.tagName.toLowerCase()).toBe("span");
48
+ });
49
+
50
+ it("omits a field when not present in contentText", async () => {
51
+ const wrapper = await mountDefined({
52
+ props: { contentText: { tooltipTitle: { tag: "h4", text: "Only a title" } } },
53
+ });
54
+ expect(wrapper.find(".display-tooltip-rich-title").exists()).toBe(true);
55
+ expect(wrapper.find(".display-tooltip-rich-body").exists()).toBe(false);
56
+ expect(wrapper.find(".display-tooltip-rich-action").exists()).toBe(false);
57
+ });
58
+
59
+ it("renders a close button wired to the tooltip id", async () => {
60
+ const wrapper = await mountDefined({ props: { tooltipId: "my-tip" } });
61
+ const closeButton = wrapper.find(".display-tooltip-close-button");
62
+ expect(closeButton.attributes("popovertarget")).toBe("nuxt-tooltip-my-tip");
63
+ expect(closeButton.attributes("popovertargetaction")).toBe("hide");
64
+ });
65
+
66
+ it("renders the close button as an inline InputButton of type button with localisable copy", async () => {
67
+ const wrapper = await mountDefined({ props: { closeButtonText: "Fermer", closeButtonAriaLabel: "Fermer l'info-bulle" } });
68
+ const closeButton = wrapper.find(".display-tooltip-close-button");
69
+ expect(closeButton.element.tagName).toBe("BUTTON");
70
+ expect(closeButton.classes()).toContain("input-button");
71
+ expect(closeButton.attributes("type")).toBe("button");
72
+ expect(closeButton.attributes("data-variant")).toBe("inline");
73
+ expect(closeButton.text()).toBe("Fermer");
74
+ expect(closeButton.attributes("aria-label")).toBe("Fermer l'info-bulle");
75
+ });
76
+
77
+ it("adds its own root class alongside DisplayTooltip's", async () => {
78
+ const wrapper = await mountDefined();
79
+ expect(wrapper.classes()).toEqual(expect.arrayContaining(["display-tooltip", "display-tooltip-rich"]));
80
+ });
81
+
82
+ it("keeps its root class when styleClassPassthrough changes", async () => {
83
+ const wrapper = await mountDefined({ props: { styleClassPassthrough: "first" } });
84
+ await wrapper.setProps({ styleClassPassthrough: ["second"] });
85
+ expect(wrapper.classes()).toEqual(expect.arrayContaining(["display-tooltip-rich", "second"]));
86
+ expect(wrapper.classes()).not.toContain("first");
87
+ });
88
+
89
+ it("forwards triggerIcon and triggerAriaLabel to the trigger", async () => {
90
+ const wrapper = await mountDefined({ props: { triggerIcon: "lucide:info", triggerAriaLabel: "Plus d'informations" } });
91
+ const trigger = wrapper.find(".display-tooltip-trigger-button");
92
+ expect(trigger.attributes("aria-label")).toBe("Plus d'informations");
93
+ expect(trigger.find(".display-tooltip-trigger-icon").classes().join(" ")).toContain("lucide:info");
94
+ });
95
+
96
+ it("falls back to a default tag when a contentText tag is empty", async () => {
97
+ const wrapper = await mountDefined({
98
+ props: {
99
+ contentText: {
100
+ tooltipTitle: { tag: "", text: "T" },
101
+ tooltipContent: { tag: " ", text: "B" },
102
+ tooltipAction: { tag: "", text: "A" },
103
+ },
104
+ },
105
+ });
106
+ expect(wrapper.find(".display-tooltip-rich-title").element.tagName).toBe("P");
107
+ expect(wrapper.find(".display-tooltip-rich-body").element.tagName).toBe("P");
108
+ expect(wrapper.find(".display-tooltip-rich-action").element.tagName).toBe("SPAN");
109
+ });
110
+
111
+ it("keeps the generated id stable and links the trigger, panel and close button", async () => {
112
+ const wrapper = await mountDefined();
113
+ const target = wrapper.find(".display-tooltip-trigger-button").attributes("popovertarget");
114
+ expect(target).toMatch(/^nuxt-tooltip-/);
115
+ expect(wrapper.find(".display-tooltip-popover").attributes("id")).toBe(target);
116
+ expect(wrapper.find(".display-tooltip-close-button").attributes("popovertarget")).toBe(target);
117
+
118
+ await wrapper.setProps({ tooltipId: "tip" });
119
+ expect(wrapper.find(".display-tooltip-close-button").attributes("popovertarget")).toBe("nuxt-tooltip-tip");
120
+ await wrapper.setProps({ tooltipId: "" });
121
+ expect(wrapper.find(".display-tooltip-close-button").attributes("popovertarget")).toBe(target);
122
+ });
123
+
124
+ it("renders the triggerContent slot", async () => {
125
+ const wrapper = await mountDefined({
126
+ slots: { triggerContent: "<span class='slot-trigger'>?</span>" },
127
+ });
128
+ expect(wrapper.find(".slot-trigger").text()).toBe("?");
129
+ });
130
+
131
+ it("applies styleClassPassthrough classes to the underlying DisplayTooltip", async () => {
132
+ const wrapper = await mountDefined({
133
+ props: { styleClassPassthrough: ["custom-class"] },
134
+ });
135
+ expect(wrapper.classes()).toContain("custom-class");
136
+ });
137
+
138
+ describe("without the Popover API", () => {
139
+ beforeEach(() => {
140
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
141
+ });
142
+
143
+ afterEach(() => {
144
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
145
+ });
146
+
147
+ it("closes from the close button", async () => {
148
+ const wrapper = await mountDefined({ props: { contentText } });
149
+ await wrapper.find(".display-tooltip-trigger-button").trigger("click");
150
+ expect(wrapper.find(".display-tooltip-popover").classes()).toContain("display-tooltip-popover-open");
151
+
152
+ await wrapper.find(".display-tooltip-close-button").trigger("click");
153
+ expect(wrapper.find(".display-tooltip-popover").classes()).not.toContain("display-tooltip-popover-open");
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,22 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`DisplayTooltipRich > renders correct HTML structure 1`] = `
4
+ "<div class="display-tooltip display-tooltip-rich" style="--_anchor-name: --tooltip-anchor-v-0-0-1;">
5
+ <div class="display-tooltip-trigger-wrapper">
6
+ <!--v-if--><button popovertarget="nuxt-tooltip-fixed" popovertargetaction="toggle" type="button" class="display-tooltip-trigger-button" aria-label="Toggle the popover" aria-expanded="false"><span class="iconify i-fa7-solid:circle-question display-tooltip-trigger-icon" aria-hidden="true"></span></button>
7
+ </div>
8
+ <div id="nuxt-tooltip-fixed" popover="" class="display-tooltip-popover">
9
+ <div class="display-tooltip-popover-content">
10
+ <div class="display-tooltip-rich-content">
11
+ <h4 class="display-tooltip-rich-title">Title</h4>
12
+ <p class="display-tooltip-rich-body">Body copy</p><span class="display-tooltip-rich-action">Learn more</span><button type="button" data-testid="input-button" data-theme="default" data-variant="inline" class="input-button display-tooltip-close-button" popovertarget="nuxt-tooltip-fixed" popovertargetaction="hide" aria-label="Close tool tip">
13
+ <!--v-if-->
14
+ <!--v-if--><span class="input-button-text">Close</span>
15
+ <!--v-if-->
16
+ <!--v-if-->
17
+ </button>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ </div>"
22
+ `;
@@ -7,6 +7,7 @@
7
7
  <div v-else class="navigation-group" :style="`--_anchor-name: --anchor-${uid}-${key};`">
8
8
  <button
9
9
  :ref="(el) => setElement(triggerEls, key, el)"
10
+ type="button"
10
11
  :popovertarget="`popovertarget-${uid}-${key}`"
11
12
  class="navigation-group-toggle"
12
13
  :aria-expanded="isOpen && activeKey === key"
@@ -3,7 +3,7 @@
3
3
  exports[`DeepExpandingMenu > renders correct HTML structure 1`] = `
4
4
  "<nav class="deep-expanding-menu">
5
5
  <div class="inner"><a href="/" class="navigation-link">Home</a>
6
- <div class="navigation-group" style="--_anchor-name: --anchor-v-0-0-1;"><button popovertarget="popovertarget-v-0-0-1" class="navigation-group-toggle" aria-expanded="false"><span>Services</span><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></button>
6
+ <div class="navigation-group" style="--_anchor-name: --anchor-v-0-0-1;"><button type="button" popovertarget="popovertarget-v-0-0-1" class="navigation-group-toggle" aria-expanded="false"><span>Services</span><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></button>
7
7
  <div id="popovertarget-v-0-0-1" class="navigation-group-panel" popover="">
8
8
  <h4 class="page-heading-4 mb-6">Our services</h4>
9
9
  <ul class="navigation-group-list">
@@ -37,6 +37,7 @@
37
37
  <ul class="markers-list">
38
38
  <li v-for="index in itemCount" :key="index" class="markers-item">
39
39
  <button
40
+ type="button"
40
41
  class="btn-marker"
41
42
  :class="[{ active: displayActiveIndex === index - 1 }]"
42
43
  :aria-label="`${jumpToItemLabel} ${index}`"
@@ -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.