srcdev-nuxt-components 9.6.0 → 9.6.2

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 (121) hide show
  1. package/.claude/skills/component-compliance-checklist.md +22 -3
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/container-glow.md +21 -8
  7. package/.claude/skills/components/display-avatar.md +27 -10
  8. package/.claude/skills/components/display-chip.md +38 -24
  9. package/.claude/skills/components/display-pill.md +58 -10
  10. package/.claude/skills/components/display-prompt.md +8 -5
  11. package/.claude/skills/components/display-toast.md +21 -4
  12. package/.claude/skills/components/glowing-border.md +20 -2
  13. package/.claude/skills/components/google-reviews.md +2 -2
  14. package/.claude/skills/components/profile-section.md +59 -21
  15. package/.claude/skills/components/responsive-header.md +11 -1
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/site-header.md +2 -2
  18. package/.claude/skills/components/social-icons-list.md +2 -0
  19. package/.claude/skills/components/tabbed-content.md +300 -0
  20. package/.claude/skills/composable-anchor-scroll.md +16 -15
  21. package/.claude/skills/index.md +8 -7
  22. package/.claude/skills/storybook-add-story.md +55 -0
  23. package/.claude/skills/theming-component-token-pattern.md +48 -0
  24. package/.claude/skills/theming-status-tokens.md +60 -0
  25. package/.vscode/srcdev-component-display-avatar.code-snippets +9 -1
  26. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  27. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  28. package/.vscode/srcdev-component-profile-section.code-snippets +27 -4
  29. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  30. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  31. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  32. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  33. package/app/assets/styles/setup/03.theming/index.css +1 -0
  34. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  35. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  36. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  37. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  38. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  39. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  40. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  41. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  42. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  43. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  44. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  45. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  46. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  47. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  48. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  49. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  50. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  51. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  52. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  53. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  54. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  55. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  56. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  57. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  58. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +33 -15
  59. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +81 -36
  60. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +108 -6
  61. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +104 -13
  62. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +4 -3
  63. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +57 -19
  64. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  65. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  66. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  67. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  68. package/app/components/02.molecules/profile-section/CONSUMER-STYLING.md +103 -13
  69. package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
  70. package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
  71. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
  72. package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
  73. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  74. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  75. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  76. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  77. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  78. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  79. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  80. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  81. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  82. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +6 -2
  83. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
  84. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  85. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  86. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  87. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  88. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  89. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  90. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  91. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
  92. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
  93. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  94. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  95. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  96. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  97. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  98. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  99. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  100. package/app/composables/useTabs.ts +148 -206
  101. package/app/types/components/display-chip.d.ts +8 -4
  102. package/app/types/components/display-toast.d.ts +2 -0
  103. package/app/types/components/index.ts +0 -1
  104. package/app/types/components/select-menu.d.ts +2 -0
  105. package/app/utils/pausableTimeout.ts +41 -0
  106. package/nuxt.config.ts +1 -1
  107. package/package.json +1 -1
  108. package/.claude/skills/components/tab-navigation.md +0 -288
  109. package/.claude/skills/components/tabs-core.md +0 -187
  110. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  111. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  112. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  113. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  114. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  115. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  116. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  117. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  118. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  119. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  120. package/app/composables/useNavCollapse.ts +0 -86
  121. package/app/types/components/nav-item.d.ts +0 -11
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { h } from "vue";
2
+ import { defineComponent, h, nextTick, ref } from "vue";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
4
  import ProfileSection from "../ProfileSection.vue";
5
5
 
@@ -52,25 +52,28 @@ describe("ProfileSection", () => {
52
52
  expect(img.attributes("alt")).toBe("Jane Doe");
53
53
  });
54
54
 
55
- it("renders 3 profile-info blocks by default", async () => {
55
+ it("renders no profile-info blocks when no profile-info slots are given", async () => {
56
56
  const wrapper = await mountSuspended(ProfileSection, {
57
57
  props: defaultProps,
58
58
  });
59
- const blocks = wrapper.findAll(".profile-section-info-block");
60
- expect(blocks.length).toBe(3);
59
+ expect(wrapper.findAll(".profile-section-info-block").length).toBe(0);
61
60
  });
62
61
 
63
- it("renders the correct number of profile-info blocks from profileInfoCount", async () => {
62
+ it("renders one block per provided profile-info slot, in numeric order, with gaps", async () => {
64
63
  const wrapper = await mountSuspended(ProfileSection, {
65
- props: { ...defaultProps, profileInfoCount: 5 },
64
+ props: defaultProps,
65
+ slots: {
66
+ "profile-info-10": "<p>Ten</p>",
67
+ "profile-info-3": "<p>Three</p>",
68
+ "profile-info-1": "<p>One</p>",
69
+ },
66
70
  });
67
- const blocks = wrapper.findAll(".profile-section-info-block");
68
- expect(blocks.length).toBe(5);
71
+ expect(wrapper.findAll(".profile-section-info-block").map((b) => b.text())).toEqual(["One", "Three", "Ten"]);
69
72
  });
70
73
 
71
74
  it("renders named profile-info slot content", async () => {
72
75
  const wrapper = await mountSuspended(ProfileSection, {
73
- props: { ...defaultProps, profileInfoCount: 2 },
76
+ props: defaultProps,
74
77
  slots: {
75
78
  "profile-info-1": "<p>First info block</p>",
76
79
  "profile-info-2": "<p>Second info block</p>",
@@ -91,11 +94,11 @@ describe("ProfileSection", () => {
91
94
  const wrapper = await mountSuspended(ProfileSection, {
92
95
  props: defaultProps,
93
96
  slots: {
94
- eyebrowText: "<p class='eyebrow'>About Natasha</p>",
97
+ eyebrowText: "<p class='eyebrow'>About the author</p>",
95
98
  },
96
99
  });
97
100
  expect(wrapper.find(".eyebrow").exists()).toBe(true);
98
- expect(wrapper.html()).toContain("About Natasha");
101
+ expect(wrapper.html()).toContain("About the author");
99
102
  });
100
103
 
101
104
  it("does not render heroText slot area when slot is not provided", async () => {
@@ -188,4 +191,134 @@ describe("ProfileSection", () => {
188
191
  expect(el.classes()).toContain("extra-class");
189
192
  expect(el.classes()).toContain("another-class");
190
193
  });
194
+
195
+ // ─── Stress test 2026-10-14 ────────────────────────────────────────────────
196
+
197
+ it("does not render an empty header when neither header slot is given", async () => {
198
+ const wrapper = await mountSuspended(ProfileSection, { props: defaultProps });
199
+ expect(wrapper.find(".profile-section-header").exists()).toBe(false);
200
+ });
201
+
202
+ it("adds and removes the header and info blocks when slots are toggled after mount", async () => {
203
+ const show = ref(false);
204
+ const Host = defineComponent({
205
+ components: { ProfileSection },
206
+ setup: () => ({ show, defaultProps }),
207
+ template: `
208
+ <ProfileSection v-bind="defaultProps">
209
+ <template v-if="show" #heroText><h2>Heading</h2></template>
210
+ <template v-if="show" #profile-info-1><p>Info</p></template>
211
+ </ProfileSection>
212
+ `,
213
+ });
214
+ const wrapper = await mountSuspended(Host);
215
+ expect(wrapper.find(".profile-section-header").exists()).toBe(false);
216
+ expect(wrapper.findAll(".profile-section-info-block").length).toBe(0);
217
+
218
+ show.value = true;
219
+ await nextTick();
220
+ expect(wrapper.find(".profile-section-header").exists()).toBe(true);
221
+ expect(wrapper.findAll(".profile-section-info-block").length).toBe(1);
222
+
223
+ show.value = false;
224
+ await nextTick();
225
+ expect(wrapper.find(".profile-section-header").exists()).toBe(false);
226
+ expect(wrapper.findAll(".profile-section-info-block").length).toBe(0);
227
+ });
228
+
229
+ it("uses the given picture width and height", async () => {
230
+ const wrapper = await mountSuspended(ProfileSection, {
231
+ props: { profilePicture: { src: "/a.jpg", alt: "A", width: 640, height: 480 } },
232
+ });
233
+ expect(wrapper.find("img").attributes("width")).toBe("640");
234
+ expect(wrapper.find("img").attributes("height")).toBe("480");
235
+ });
236
+
237
+ it.each([0, -100, Number.NaN, Number.POSITIVE_INFINITY])(
238
+ "falls back to 828x1104 for an invalid picture size (%s)",
239
+ async (size) => {
240
+ const wrapper = await mountSuspended(ProfileSection, {
241
+ props: { profilePicture: { src: "/a.jpg", alt: "A", width: size, height: size } },
242
+ });
243
+ expect(wrapper.find("img").attributes("width")).toBe("828");
244
+ expect(wrapper.find("img").attributes("height")).toBe("1104");
245
+ }
246
+ );
247
+
248
+ it("renders HTML-like alt text as text", async () => {
249
+ const wrapper = await mountSuspended(ProfileSection, {
250
+ props: { profilePicture: { src: "/a.jpg", alt: "<script>alert(1)</script>" } },
251
+ });
252
+ expect(wrapper.find("script").exists()).toBe(false);
253
+ expect(wrapper.find("img").attributes("alt")).toBe("<script>alert(1)</script>");
254
+ });
255
+
256
+ // ─── headerPlacement ─────────────────────────────────────────────────────────────────────────────────────────────────────────
257
+
258
+ const headerSlots = {
259
+ eyebrowText: "<p class='eyebrow'>About</p>",
260
+ heroText: (props: Record<string, unknown>) => h("h2", { id: props.headingId, class: "hero-heading" }, "Heading"),
261
+ };
262
+
263
+ it("renders the header above the grid by default", async () => {
264
+ const wrapper = await mountSuspended(ProfileSection, { props: defaultProps, slots: headerSlots });
265
+ expect(wrapper.find(".profile-section > .profile-section-header").exists()).toBe(true);
266
+ expect(wrapper.find(".profile-section-picture .profile-section-header").exists()).toBe(false);
267
+ expect(wrapper.attributes("data-header-placement")).toBe("above");
268
+ });
269
+
270
+ it("renders the header inside the picture frame, before the image, for picture-bottom", async () => {
271
+ const wrapper = await mountSuspended(ProfileSection, {
272
+ props: { ...defaultProps, headerPlacement: "picture-bottom" },
273
+ slots: headerSlots,
274
+ });
275
+ expect(wrapper.attributes("data-header-placement")).toBe("picture-bottom");
276
+ expect(wrapper.findAll(".profile-section-header").length).toBe(1);
277
+ const picture = wrapper.find(".profile-section-picture");
278
+ expect(picture.element.firstElementChild?.classList.contains("profile-section-header")).toBe(true);
279
+ expect(picture.find(".eyebrow").exists()).toBe(true);
280
+ expect(picture.find(".hero-heading").exists()).toBe(true);
281
+ });
282
+
283
+ it("keeps aria-labelledby pointing at the heading when it is inside the picture", async () => {
284
+ const wrapper = await mountSuspended(ProfileSection, {
285
+ props: { ...defaultProps, tag: "section", headerPlacement: "picture-bottom" },
286
+ slots: headerSlots,
287
+ });
288
+ expect(wrapper.attributes("aria-labelledby")).toBe(wrapper.find(".hero-heading").attributes("id"));
289
+ });
290
+
291
+ it("renders no header in the picture when no header slot is given", async () => {
292
+ const wrapper = await mountSuspended(ProfileSection, {
293
+ props: { ...defaultProps, headerPlacement: "picture-bottom" },
294
+ });
295
+ expect(wrapper.find(".profile-section-header").exists()).toBe(false);
296
+ });
297
+
298
+ it("moves the header when headerPlacement changes after mount", async () => {
299
+ const wrapper = await mountSuspended(ProfileSection, { props: defaultProps, slots: headerSlots });
300
+ await wrapper.setProps({ headerPlacement: "picture-bottom" });
301
+ expect(wrapper.find(".profile-section-picture .profile-section-header").exists()).toBe(true);
302
+ expect(wrapper.find(".profile-section > .profile-section-header").exists()).toBe(false);
303
+ });
304
+
305
+ it("renders the header inside the picture for picture-top too", async () => {
306
+ const wrapper = await mountSuspended(ProfileSection, {
307
+ props: { ...defaultProps, headerPlacement: "picture-top" },
308
+ slots: headerSlots,
309
+ });
310
+ expect(wrapper.attributes("data-header-placement")).toBe("picture-top");
311
+ expect(wrapper.find(".profile-section-picture > .profile-section-header").exists()).toBe(true);
312
+ expect(wrapper.find(".profile-section > .profile-section-header").exists()).toBe(false);
313
+ });
314
+
315
+ it("moves the header back above the grid when headerPlacement returns to above", async () => {
316
+ const wrapper = await mountSuspended(ProfileSection, {
317
+ props: { ...defaultProps, headerPlacement: "picture-top" },
318
+ slots: headerSlots,
319
+ });
320
+ await wrapper.setProps({ headerPlacement: "above" });
321
+ expect(wrapper.find(".profile-section > .profile-section-header").exists()).toBe(true);
322
+ expect(wrapper.find(".profile-section-picture .profile-section-header").exists()).toBe(false);
323
+ });
191
324
  });
@@ -1,19 +1,14 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`ProfileSection > renders correct HTML structure 1`] = `
4
- "<div class="profile-section custom-class">
5
- <header class="profile-section-header">
6
- <!--v-if-->
7
- <!--v-if-->
8
- </header>
4
+ "<div class="profile-section custom-class" data-header-placement="above">
5
+ <!--v-if-->
9
6
  <div class="profile-section-inner">
10
- <div class="profile-section-picture"><img width="828" height="1104" data-nuxt-img="" srcset="/_ipx/s_828x1104/images/test.jpg 1x, /_ipx/s_1656x2208/images/test.jpg 2x" alt="Test profile picture" class="profile-section-image" src="/_ipx/s_828x1104/images/test.jpg"></div>
7
+ <div class="profile-section-picture">
8
+ <!--v-if--><img width="828" height="1104" data-nuxt-img="" srcset="/_ipx/s_828x1104/images/test.jpg 1x, /_ipx/s_1656x2208/images/test.jpg 2x" alt="Test profile picture" class="profile-section-image" src="/_ipx/s_828x1104/images/test.jpg">
9
+ </div>
11
10
  <div class="profile-section-info">
12
- <div class="profile-section-info-content">
13
- <div class="profile-section-info-block"></div>
14
- <div class="profile-section-info-block"></div>
15
- <div class="profile-section-info-block"></div>
16
- </div>
11
+ <div class="profile-section-info-content"></div>
17
12
  <!--v-if-->
18
13
  </div>
19
14
  </div>
@@ -11,6 +11,10 @@ by `AlertContent`, or `AlertMaskedContent` with `masked`, and styled by their to
11
11
  `02.molecules/alert-content/CONSUMER-STYLING.md` and `02.molecules/alert-masked-content/CONSUMER-STYLING.md`.
12
12
  The palette comes from the `data-theme` attribute on `.display-prompt-wrapper` (`--theme-*` tokens).
13
13
 
14
+ To shorten long titles or messages, use AlertContent's line-clamp tokens, which inherit through the
15
+ prompt: `--alert-content-title-line-clamp` and `--alert-content-text-line-clamp` (default `none`,
16
+ `1` is single-line ellipsis).
17
+
14
18
  > Changed 2026-09-28: `--display-prompt-transition-duration` is new (the transition was a hardcoded
15
19
  > `all 200ms`), and the transition now respects reduced motion.
16
20
 
@@ -22,9 +26,13 @@ No private `--_` tokens.
22
26
 
23
27
  | Hook | Element | When |
24
28
  |---|---|---|
25
- | `.closed` | `.display-prompt` (root) | Dismissed without a parent `v-model`; the root is also `inert` |
29
+ | `[data-state="open\|closed"]` | `.display-prompt` (root) | `closed` once dismissed without a parent `v-model`; the root is then also `inert` |
26
30
  | `[data-theme="info\|success\|warning\|error"]` | `.display-prompt-wrapper` | The resolved `theme` |
27
31
 
32
+ > Changed 2026-10-08: the bare `.closed` class is now `data-state="closed"` (and `data-state="open"` before
33
+ > dismissal), so a consumer's own `.closed` utility class can no longer match the prompt. Replace
34
+ > `.display-prompt.closed` selectors with `.display-prompt[data-state="closed"]`.
35
+
28
36
  > Changed 2026-09-28: root class renamed from `.display-prompt-core`. A closed prompt is now `inert`
29
37
  > (its dismiss button used to stay in the tab order while invisible), and the root is only focusable
30
38
  > (`tabindex="-1"`) when `use-auto-focus` is on.
@@ -2,7 +2,7 @@
2
2
  <div
3
3
  ref="promptElementRef"
4
4
  class="display-prompt"
5
- :class="[{ closed: !componentOpen }]"
5
+ :data-state="componentOpen ? 'open' : 'closed'"
6
6
  :data-test-id="`display-prompt-${resolved.theme}`"
7
7
  :tabindex="resolved.useAutoFocus ? -1 : undefined"
8
8
  :inert="!componentOpen || undefined"
@@ -114,7 +114,7 @@ onMounted(() => {
114
114
  transition: none;
115
115
  }
116
116
 
117
- &.closed {
117
+ &[data-state="closed"] {
118
118
  grid-template-rows: 0fr;
119
119
  opacity: 0;
120
120
  pointer-events: none;
@@ -1,11 +1,33 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { ref } from "vue";
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
3
  import StorybookComponent from "../DisplayPrompt.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { MediaCanvas } from "~/types/components";
4
6
 
5
- export default {
7
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
8
+
9
+ // Story-only args: drive AlertContent's CSS tokens, not DisplayPrompt props. See their argTypes entries.
10
+ type StoryArgs = InstanceType<typeof StorybookComponent>["$props"] & {
11
+ titleLineClamp: LineClamp;
12
+ textLineClamp: LineClamp;
13
+ };
14
+
15
+ const lineClampArgType = (token: string, example: string) => ({
16
+ control: "select" as const,
17
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
18
+ description:
19
+ `**Story control, not a prop.** Sets the \`${token}\` CSS token on a wrapper so you can try it here. ` +
20
+ `To use it in an app, set the token in your own CSS, e.g. \`.site-notice { ${token}: ${example}; }\`. ` +
21
+ "`1` is single-line ellipsis, `none` (the default) shows everything. Don't clamp messages people need to read in full.",
22
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
23
+ });
24
+
25
+ const meta: Meta<StoryArgs> = {
6
26
  title: "Molecules/DisplayPrompt",
7
27
  component: StorybookComponent,
8
28
  argTypes: {
29
+ titleLineClamp: lineClampArgType("--alert-content-title-line-clamp", "1"),
30
+ textLineClamp: lineClampArgType("--alert-content-text-line-clamp", "3"),
9
31
  theme: {
10
32
  control: { type: "inline-radio" },
11
33
  options: ["info", "success", "warning", "error"],
@@ -46,84 +68,199 @@ export default {
46
68
  masked: false,
47
69
  closeLabel: "Close this prompt",
48
70
  styleClassPassthrough: [],
71
+ titleLineClamp: "none",
72
+ textLineClamp: "none",
49
73
  },
50
- } as Meta<typeof StorybookComponent>;
74
+ };
51
75
 
52
- const Template: StoryFn<typeof StorybookComponent> = (args) => ({
53
- components: { StorybookComponent },
54
- setup() {
55
- const model = ref(false);
56
- return { args, model };
57
- },
58
- template: `
59
- <div style="padding: 2rem; max-width: 640px;">
60
- <StorybookComponent
61
- v-model="model"
62
- :theme="args.theme"
63
- :dismissible="args.dismissible"
64
- :use-auto-focus="args.useAutoFocus"
65
- :masked="args.masked"
66
- :close-label="args.closeLabel"
67
- :style-class-passthrough="args.styleClassPassthrough"
68
- >
69
- <template #title>Prompt title</template>
70
- <template #content>This is the prompt content. It can contain any information you want to communicate to the user.</template>
71
- </StorybookComponent>
72
- </div>
73
- `,
74
- });
76
+ export default meta;
77
+ type Story = StoryObj<StoryArgs>;
75
78
 
76
- export const Default = Template.bind({});
79
+ function useStorySetup(args: StoryArgs) {
80
+ const tokenStyles = computed(() => ({
81
+ "--alert-content-title-line-clamp": args.titleLineClamp,
82
+ "--alert-content-text-line-clamp": args.textLineClamp,
83
+ }));
84
+ return { args, tokenStyles };
85
+ }
77
86
 
78
- export const InfoTheme = Template.bind({});
79
- InfoTheme.args = {
80
- theme: "info",
81
- dismissible: true,
87
+ const Template: Story = {
88
+ render: (args) => ({
89
+ components: { StorybookComponent },
90
+ setup() {
91
+ const model = ref(false);
92
+ return { ...useStorySetup(args), model };
93
+ },
94
+ template: `
95
+ <div :style="tokenStyles" style="padding: 2rem; max-width: 640px;">
96
+ <StorybookComponent
97
+ v-model="model"
98
+ :theme="args.theme"
99
+ :dismissible="args.dismissible"
100
+ :use-auto-focus="args.useAutoFocus"
101
+ :masked="args.masked"
102
+ :close-label="args.closeLabel"
103
+ :style-class-passthrough="args.styleClassPassthrough"
104
+ >
105
+ <template #title>Prompt title</template>
106
+ <template #content>This is the prompt content. It can contain any information you want to communicate to the user.</template>
107
+ </StorybookComponent>
108
+ </div>
109
+ `,
110
+ }),
82
111
  };
83
112
 
84
- export const SuccessTheme = Template.bind({});
85
- SuccessTheme.args = {
86
- theme: "success",
87
- dismissible: true,
88
- };
113
+ export const Default: Story = { ...Template };
89
114
 
90
- export const WarningTheme = Template.bind({});
91
- WarningTheme.args = {
92
- theme: "warning",
93
- dismissible: true,
94
- };
115
+ export const InfoTheme: Story = { ...Template, args: { theme: "info", dismissible: true } };
95
116
 
96
- export const ErrorTheme = Template.bind({});
97
- ErrorTheme.args = {
98
- theme: "error",
99
- dismissible: true,
100
- };
117
+ export const SuccessTheme: Story = { ...Template, args: { theme: "success", dismissible: true } };
118
+
119
+ export const WarningTheme: Story = { ...Template, args: { theme: "warning", dismissible: true } };
120
+
121
+ export const ErrorTheme: Story = { ...Template, args: { theme: "error", dismissible: true } };
122
+
123
+ export const Dismissible: Story = { ...Template, args: { theme: "info", dismissible: true } };
101
124
 
102
- export const Dismissible = Template.bind({});
103
- Dismissible.args = {
104
- theme: "info",
105
- dismissible: true,
125
+ export const AllThemesDismissible: Story = {
126
+ render: () => ({
127
+ components: { StorybookComponent },
128
+ setup() {
129
+ return { themes: ["info", "success", "warning", "error"] as const };
130
+ },
131
+ template: `
132
+ <div style="padding: 2rem; display: flex; flex-direction: column; gap: 1.6rem; max-width: 640px;">
133
+ <StorybookComponent
134
+ v-for="theme in themes"
135
+ :key="theme"
136
+ :theme="theme"
137
+ :dismissible="true"
138
+ >
139
+ <template #title>{{ theme.charAt(0).toUpperCase() + theme.slice(1) }} prompt</template>
140
+ <template #content>This is the {{ theme }} variant of the prompt component.</template>
141
+ </StorybookComponent>
142
+ </div>
143
+ `,
144
+ }),
145
+ // Fixed showcase: hardcoded values, so Controls would do nothing here.
146
+ parameters: { controls: { disable: true } },
106
147
  };
107
148
 
108
- export const AllThemesDismissible: StoryFn<typeof StorybookComponent> = () => ({
109
- components: { StorybookComponent },
110
- setup() {
111
- return { themes: ["info", "success", "warning", "error"] as const };
149
+ const stressPrompts = [
150
+ {
151
+ key: "german",
152
+ theme: "error",
153
+ title: "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft",
154
+ content:
155
+ "Ihre Sitzung läuft in Kürze ab. Bitte speichern Sie alle ungespeicherten Änderungen, bevor Sie fortfahren, sonst gehen sie verloren. https://example.com/konto/sitzung/a-very-long-path-segment-without-any-spaces-at-all-in-it",
156
+ closeLabel: "Diese Benachrichtigung schließen und nicht erneut anzeigen",
112
157
  },
113
- template: `
114
- <div style="padding: 2rem; display: flex; flex-direction: column; gap: 1.6rem; max-width: 640px;">
115
- <StorybookComponent
116
- v-for="theme in themes"
117
- :key="theme"
118
- :theme="theme"
119
- :dismissible="true"
120
- >
121
- <template #title>{{ theme.charAt(0).toUpperCase() + theme.slice(1) }} prompt</template>
122
- <template #content>This is the {{ theme }} variant of the prompt component.</template>
123
- </StorybookComponent>
124
- </div>
125
- `,
126
- });
158
+ {
159
+ key: "emoji-html",
160
+ theme: "warning",
161
+ title: "🚨🔥 Heads up <script>alert('xss')</script>",
162
+ content: "<b>Not bold</b> &amp; <img src=x onerror=alert(1)> should all render as plain text. 👍🏽👨‍👩‍👧‍👦",
163
+ decoratorIcon: "🚨",
164
+ },
165
+ {
166
+ key: "rtl",
167
+ theme: "info",
168
+ dir: "rtl",
169
+ title: "تنبيه مهم بخصوص حسابك",
170
+ content: "تم تحديث شروط الخدمة. يرجى مراجعة التغييرات قبل المتابعة لاستخدام الموقع.",
171
+ closeLabel: "إغلاق",
172
+ },
173
+ {
174
+ key: "masked",
175
+ theme: "success",
176
+ masked: true,
177
+ title: "Gespeichert: Kundendienstmitarbeiterkontaktaufnahmeformular",
178
+ content: "Masked variant over a gradient, with an unbroken German compound in the title.",
179
+ },
180
+ {
181
+ key: "title-only",
182
+ theme: "success",
183
+ title: "Ok",
184
+ },
185
+ {
186
+ key: "empty-title",
187
+ theme: "info",
188
+ title: "",
189
+ content: "An empty title slot, a custom close icon and nothing else.",
190
+ customCloseIcon: true,
191
+ },
192
+ {
193
+ key: "content-only",
194
+ theme: "warning",
195
+ content: "x",
196
+ },
197
+ ];
127
198
 
128
- // Fixed showcase: hardcoded values, so Controls would do nothing here.
129
- AllThemesDismissible.parameters = { controls: { disable: true } };
199
+ /**
200
+ * Stress test — hostile content across several prompts, inside the CanvasSwitcher so it can be
201
+ * checked at every width.
202
+ */
203
+ export const StressTest: Story = {
204
+ name: "Stress Test (Worst-Case Data)",
205
+ args: { titleLineClamp: "none", textLineClamp: "none" },
206
+ decorators: [
207
+ (story, context) => ({
208
+ components: { story, CanvasSwitcher },
209
+ setup() {
210
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
211
+ return { canvasName };
212
+ },
213
+ template: `
214
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
215
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
216
+ </div>
217
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
218
+ <story />
219
+ </div>
220
+ `,
221
+ }),
222
+ ],
223
+ render: (args) => ({
224
+ components: { StorybookComponent },
225
+ setup() {
226
+ return { ...useStorySetup(args), stressPrompts };
227
+ },
228
+ template: `
229
+ <div :style="tokenStyles" style="display: grid; gap: 1.6rem;">
230
+ <div
231
+ v-for="prompt in stressPrompts"
232
+ :key="prompt.key"
233
+ :dir="prompt.dir"
234
+ :style="prompt.masked ? 'padding: 1.6rem; background: linear-gradient(135deg, oklch(40% 0.15 250), oklch(25% 0.1 300));' : undefined"
235
+ >
236
+ <StorybookComponent
237
+ :theme="prompt.theme"
238
+ :masked="prompt.masked ?? false"
239
+ :close-label="prompt.closeLabel"
240
+ dismissible
241
+ >
242
+ <template v-if="prompt.decoratorIcon" #customDecoratorIcon>{{ prompt.decoratorIcon }}</template>
243
+ <template v-if="prompt.title !== undefined" #title>{{ prompt.title }}</template>
244
+ <template v-if="prompt.content" #content>{{ prompt.content }}</template>
245
+ <template v-if="prompt.customCloseIcon" #customCloseIcon>✕</template>
246
+ </StorybookComponent>
247
+ </div>
248
+ </div>
249
+ `,
250
+ }),
251
+ parameters: {
252
+ layout: "fullscreen",
253
+ initialCanvas: "mobileCanvas",
254
+ docs: {
255
+ description: {
256
+ story:
257
+ "Hostile content: an unbroken German compound as the title, long German copy ending in a long URL, a long German close label, " +
258
+ "emoji at the start and as the decorator icon, HTML-like text (must render as text), right-to-left Arabic, the masked variant over a gradient, " +
259
+ "a two-character title, an empty title slot with a custom close icon, and a one-character message with no title. " +
260
+ "Check at every CanvasSwitcher width that nothing overflows or gets clipped, unbroken text wraps, the icon and dismiss button keep their size, " +
261
+ "the RTL prompt mirrors, and dismissing any prompt collapses it to zero height (the story's own grid gap stays)." +
262
+ "The two line-clamp controls are AlertContent CSS tokens (story only), not props: try `1` to see single-line ellipsis.",
263
+ },
264
+ },
265
+ },
266
+ };
@@ -33,9 +33,9 @@ describe("DisplayPrompt", () => {
33
33
  expect(root(w).exists()).toBe(true);
34
34
  });
35
35
 
36
- it("is visible by default (no closed class)", async () => {
36
+ it("is open by default (data-state=open)", async () => {
37
37
  const w = await mountSuspended(DisplayPrompt);
38
- expect(root(w).classes()).not.toContain("closed");
38
+ expect(root(w).attributes("data-state")).toBe("open");
39
39
  });
40
40
 
41
41
  it("is not in the tab order by default", async () => {
@@ -104,6 +104,19 @@ describe("DisplayPrompt", () => {
104
104
  expect(w.find("[data-test-id='alert-content']").text()).toContain("Detailed explanation here");
105
105
  });
106
106
 
107
+ it("does not render the title element when the title slot is not provided", async () => {
108
+ const w = await mountSuspended(DisplayPrompt, { slots: { content: "x" } });
109
+ expect(w.find("[data-test-id='alert-title']").exists()).toBe(false);
110
+ });
111
+
112
+ it("renders HTML-like slot text as plain text", async () => {
113
+ const w = await mountSuspended(DisplayPrompt, {
114
+ slots: { title: () => "<script>alert('xss')</script>" },
115
+ });
116
+ expect(w.find("[data-test-id='alert-title'] script").exists()).toBe(false);
117
+ expect(w.find("[data-test-id='alert-title']").text()).toContain("<script>");
118
+ });
119
+
107
120
  it("does not render content element when content slot is empty", async () => {
108
121
  const w = await mountSuspended(DisplayPrompt);
109
122
  expect(w.find("[data-test-id='alert-content']").exists()).toBe(false);
@@ -123,11 +136,11 @@ describe("DisplayPrompt", () => {
123
136
 
124
137
  // ─── Dismiss behaviour (no parent model) ─────────────────────────────────
125
138
 
126
- it("adds the closed class after the dismiss button is clicked", async () => {
139
+ it("sets data-state=closed after the dismiss button is clicked", async () => {
127
140
  const w = await mountSuspended(DisplayPrompt, { props: { dismissible: true } });
128
141
  await w.find("[data-test-id='alert-dismiss']").trigger("click");
129
142
  await nextTick();
130
- expect(root(w).classes()).toContain("closed");
143
+ expect(root(w).attributes("data-state")).toBe("closed");
131
144
  });
132
145
 
133
146
  it("makes the closed prompt inert so its controls leave the tab order", async () => {
@@ -168,14 +181,14 @@ describe("DisplayPrompt", () => {
168
181
  expect(emitted![0]).toEqual([false]);
169
182
  });
170
183
 
171
- it("does not add the closed class when dismissed via parent model", async () => {
184
+ it("stays data-state=open when dismissed via parent model", async () => {
172
185
  const w = await mountSuspended(DisplayPrompt, {
173
186
  props: { dismissible: true, modelValue: true },
174
187
  });
175
188
  await w.find("[data-test-id='alert-dismiss']").trigger("click");
176
189
  await nextTick();
177
190
  // componentOpen is unchanged — parent controls visibility via modelValue
178
- expect(root(w).classes()).not.toContain("closed");
191
+ expect(root(w).attributes("data-state")).toBe("open");
179
192
  });
180
193
 
181
194
  // ─── styleClassPassthrough ────────────────────────────────────────────────