srcdev-nuxt-components 9.6.5 → 9.6.6

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 (26) hide show
  1. package/.claude/rules/consumer/a11y-no-focus-steal.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +5 -1
  3. package/.claude/skills/components/display-toast.md +6 -3
  4. package/.claude/skills/components/social-icons-list.md +32 -5
  5. package/.claude/skills/css-dynamic-border-radius.md +114 -0
  6. package/.claude/skills/index.md +2 -1
  7. package/.claude/skills/storybook-add-story.md +6 -0
  8. package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
  9. package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
  10. package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
  11. package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
  12. package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
  13. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
  14. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
  15. package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
  16. package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
  17. package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
  18. package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
  19. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
  20. package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
  21. package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
  22. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
  23. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
  24. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
  25. package/app/types/components/social-icons-list.d.ts +9 -0
  26. package/package.json +1 -1
@@ -1,8 +1,22 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { h } from "vue";
2
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
4
  import SocialIconsList from "../SocialIconsList.vue";
4
5
  import type { ISocialIcon } from "~/types/components/social-icons-list.d";
5
6
 
7
+ const mounted: { unmount: () => void }[] = [];
8
+
9
+ async function mountList(props: Record<string, unknown>) {
10
+ const wrapper = await mountSuspended(SocialIconsList, { props: props as { items: ISocialIcon[] } });
11
+ mounted.push(wrapper);
12
+ return wrapper;
13
+ }
14
+
15
+ afterEach(() => {
16
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
17
+ vi.restoreAllMocks();
18
+ });
19
+
6
20
  const defaultItems: ISocialIcon[] = [
7
21
  {
8
22
  networkName: "Facebook",
@@ -26,120 +40,177 @@ const defaultItems: ISocialIcon[] = [
26
40
 
27
41
  describe("SocialIconsList", () => {
28
42
  it("mounts without error", async () => {
29
- const wrapper = await mountSuspended(SocialIconsList, {
30
- props: { items: defaultItems },
31
- });
43
+ const wrapper = await mountList({ items: defaultItems });
32
44
  expect(wrapper.vm).toBeTruthy();
33
45
  });
34
46
 
35
47
  it("renders as a ul element", async () => {
36
- const wrapper = await mountSuspended(SocialIconsList, {
37
- props: { items: defaultItems },
38
- });
48
+ const wrapper = await mountList({ items: defaultItems });
39
49
  expect(wrapper.element.tagName.toLowerCase()).toBe("ul");
40
50
  });
41
51
 
42
52
  it("renders the correct number of list items", async () => {
43
- const wrapper = await mountSuspended(SocialIconsList, {
44
- props: { items: defaultItems },
45
- });
46
- expect(wrapper.findAll(".social-icon-item").length).toBe(defaultItems.length);
53
+ const wrapper = await mountList({ items: defaultItems });
54
+ expect(wrapper.findAll(".social-icons-list-item").length).toBe(defaultItems.length);
47
55
  });
48
56
 
49
57
  it("renders the correct href for each item", async () => {
50
- const wrapper = await mountSuspended(SocialIconsList, {
51
- props: { items: defaultItems },
52
- });
53
- const links = wrapper.findAll(".social-icon-link");
58
+ const wrapper = await mountList({ items: defaultItems });
59
+ const links = wrapper.findAll(".social-icons-list-link");
54
60
  defaultItems.forEach((item, i) => {
55
61
  expect(links[i]!.attributes("href")).toBe(`${item.baseHref}${item.profileId}`);
56
62
  });
57
63
  });
58
64
 
59
65
  it("renders aria-label on each link using networkName", async () => {
60
- const wrapper = await mountSuspended(SocialIconsList, {
61
- props: { items: defaultItems },
62
- });
63
- const links = wrapper.findAll(".social-icon-link");
66
+ const wrapper = await mountList({ items: defaultItems });
67
+ const links = wrapper.findAll(".social-icons-list-link");
64
68
  defaultItems.forEach((item, i) => {
65
69
  expect(links[i]!.attributes("aria-label")).toBe(`${item.networkName} profile (opens in a new tab)`);
66
70
  });
67
71
  });
68
72
 
69
73
  it("adds rel='noopener noreferrer' to each link", async () => {
70
- const wrapper = await mountSuspended(SocialIconsList, {
71
- props: { items: defaultItems },
72
- });
73
- const links = wrapper.findAll(".social-icon-link");
74
+ const wrapper = await mountList({ items: defaultItems });
75
+ const links = wrapper.findAll(".social-icons-list-link");
74
76
  links.forEach((link) => {
75
77
  expect(link.attributes("rel")).toBe("noopener noreferrer");
76
78
  });
77
79
  });
78
80
 
79
81
  it("opens each link in a new tab", async () => {
80
- const wrapper = await mountSuspended(SocialIconsList, {
81
- props: { items: defaultItems },
82
- });
83
- const links = wrapper.findAll(".social-icon-link");
82
+ const wrapper = await mountList({ items: defaultItems });
83
+ const links = wrapper.findAll(".social-icons-list-link");
84
84
  links.forEach((link) => {
85
85
  expect(link.attributes("target")).toBe("_blank");
86
86
  });
87
87
  });
88
88
 
89
89
  it("uses the default aria-label on the list", async () => {
90
- const wrapper = await mountSuspended(SocialIconsList, {
91
- props: { items: defaultItems },
92
- });
90
+ const wrapper = await mountList({ items: defaultItems });
93
91
  expect(wrapper.attributes("aria-label")).toBe("Social media profiles");
94
92
  });
95
93
 
96
94
  it("uses a custom label prop as aria-label on the list", async () => {
97
- const wrapper = await mountSuspended(SocialIconsList, {
98
- props: { items: defaultItems, label: "Follow us" },
99
- });
95
+ const wrapper = await mountList({ items: defaultItems, label: "Follow us" });
100
96
  expect(wrapper.attributes("aria-label")).toBe("Follow us");
101
97
  });
102
98
 
103
99
  it("renders an empty list when items is empty", async () => {
104
- const wrapper = await mountSuspended(SocialIconsList, {
105
- props: { items: [] },
106
- });
107
- expect(wrapper.findAll(".social-icon-item").length).toBe(0);
100
+ const wrapper = await mountList({ items: [] });
101
+ expect(wrapper.findAll(".social-icons-list-item").length).toBe(0);
108
102
  });
109
103
 
110
104
  it("applies styleClassPassthrough classes to the root element", async () => {
111
- const wrapper = await mountSuspended(SocialIconsList, {
112
- props: { items: defaultItems, styleClassPassthrough: ["custom-class", "another-class"] },
113
- });
105
+ const wrapper = await mountList({ items: defaultItems, styleClassPassthrough: ["custom-class", "another-class"] });
114
106
  expect(wrapper.classes()).toContain("custom-class");
115
107
  expect(wrapper.classes()).toContain("another-class");
116
108
  });
117
109
 
118
110
  it("renders the correct HTML structure", async () => {
119
- const wrapper = await mountSuspended(SocialIconsList, {
120
- props: { items: defaultItems },
121
- });
111
+ const wrapper = await mountList({ items: defaultItems });
122
112
  expect(wrapper.html()).toMatchSnapshot();
123
113
  });
124
114
  });
125
115
 
126
116
  describe("SocialIconsList link labels", () => {
127
117
  it("uses linkLabelTemplate for each link", async () => {
128
- const wrapper = await mountSuspended(SocialIconsList, {
129
- props: { items: defaultItems, linkLabelTemplate: "Profil {network}" },
130
- });
131
- expect(wrapper.findAll(".social-icon-link")[0]!.attributes("aria-label")).toBe("Profil Facebook");
118
+ const wrapper = await mountList({ items: defaultItems, linkLabelTemplate: "Profil {network}" });
119
+ expect(wrapper.findAll(".social-icons-list-link")[0]!.attributes("aria-label")).toBe("Profil Facebook");
132
120
  });
133
121
 
134
122
  it("lets an item's own label override the template", async () => {
123
+ const wrapper = await mountList({ items: [{ ...defaultItems[0]!, label: "Our Facebook page" }] });
124
+ expect(wrapper.find(".social-icons-list-link").attributes("aria-label")).toBe("Our Facebook page");
125
+ });
126
+
127
+ it("hides the icon from assistive tech (the link carries the name)", async () => {
128
+ const wrapper = await mountList({ items: defaultItems });
129
+ expect(wrapper.find(".social-icons-list-icon-wrapper").attributes("aria-hidden")).toBe("true");
130
+ });
131
+ });
132
+
133
+ describe("SocialIconsList hostile data", () => {
134
+ it("announces as a list even with list-style: none (Safari/VoiceOver)", async () => {
135
+ const wrapper = await mountList({ items: defaultItems });
136
+ expect(wrapper.attributes("role")).toBe("list");
137
+ });
138
+
139
+ it("renders every item when two share a networkName", async () => {
140
+ const twoInstagrams: ISocialIcon[] = [
141
+ { ...defaultItems[1]!, profileId: "salon" },
142
+ { ...defaultItems[1]!, profileId: "academy" },
143
+ ];
144
+ const wrapper = await mountList({ items: twoInstagrams });
145
+ const hrefs = wrapper.findAll(".social-icons-list-link").map((link) => link.attributes("href"));
146
+ expect(hrefs).toEqual(["https://www.instagram.com/salon", "https://www.instagram.com/academy"]);
147
+ });
148
+
149
+ it("skips an item whose href isn't http(s), mailto, tel or site-relative", async () => {
150
+ vi.spyOn(console, "warn").mockImplementation(() => {});
151
+ const wrapper = await mountList({
152
+ items: [
153
+ { ...defaultItems[0]!, baseHref: "javascript:", profileId: "alert(1)" },
154
+ { ...defaultItems[0]!, baseHref: " JavaScript:", profileId: "alert(1)" },
155
+ { ...defaultItems[0]!, baseHref: "//evil.example/", profileId: "x" },
156
+ defaultItems[1]!,
157
+ ],
158
+ });
159
+ const links = wrapper.findAll(".social-icons-list-link");
160
+ expect(links.length).toBe(1);
161
+ expect(links[0]!.attributes("href")).toBe("https://www.instagram.com/testhandle");
162
+ });
163
+
164
+ it("keeps mailto, tel and site-relative hrefs", async () => {
165
+ const wrapper = await mountList({
166
+ items: [
167
+ { networkName: "Email", iconName: "mdi:email", baseHref: "mailto:", profileId: "hello@example.com" },
168
+ { networkName: "Phone", iconName: "mdi:phone", baseHref: "tel:", profileId: "+441234567890" },
169
+ { networkName: "Contact", iconName: "mdi:account", baseHref: "/", profileId: "contact" },
170
+ ],
171
+ });
172
+ const hrefs = wrapper.findAll(".social-icons-list-link").map((link) => link.attributes("href"));
173
+ expect(hrefs).toEqual(["mailto:hello@example.com", "tel:+441234567890", "/contact"]);
174
+ });
175
+ });
176
+
177
+ describe("SocialIconsList labels, slots and brand colour", () => {
178
+ it("renders no visible label by default", async () => {
179
+ const wrapper = await mountList({ items: defaultItems });
180
+ expect(wrapper.find(".social-icons-list-label").exists()).toBe(false);
181
+ });
182
+
183
+ it("shows each networkName as visible text with showLabel, keeping the aria-label", async () => {
184
+ const wrapper = await mountList({ items: defaultItems, showLabel: true });
185
+ const labels = wrapper.findAll(".social-icons-list-label").map((label) => label.text());
186
+ expect(labels).toEqual(["Facebook", "Instagram", "YouTube"]);
187
+ expect(wrapper.find(".social-icons-list-link").attributes("aria-label")).toBe("Facebook profile (opens in a new tab)");
188
+ });
189
+
190
+ it("sets --social-icons-list-brand-colour on an item only when it has a brandColour", async () => {
191
+ const wrapper = await mountList({ items: [{ ...defaultItems[0]!, brandColour: "#1877f2" }, defaultItems[1]!] });
192
+ const items = wrapper.findAll(".social-icons-list-item");
193
+ expect((items[0]!.element as HTMLElement).style.getPropertyValue("--social-icons-list-brand-colour")).toBe("#1877f2");
194
+ expect((items[1]!.element as HTMLElement).style.getPropertyValue("--social-icons-list-brand-colour")).toBe("");
195
+ });
196
+
197
+ it("replaces the icon with the scoped icon slot, inside the wrapper", async () => {
135
198
  const wrapper = await mountSuspended(SocialIconsList, {
136
- props: { items: [{ ...defaultItems[0]!, label: "Our Facebook page" }] },
199
+ props: { items: defaultItems },
200
+ slots: { icon: ({ item }: { item: ISocialIcon }) => h("b", { class: "custom-icon" }, item.networkName[0]) },
137
201
  });
138
- expect(wrapper.find(".social-icon-link").attributes("aria-label")).toBe("Our Facebook page");
202
+ mounted.push(wrapper);
203
+ const custom = wrapper.findAll(".social-icons-list-icon-wrapper .custom-icon");
204
+ expect(custom.map((el) => el.text())).toEqual(["F", "I", "Y"]);
205
+ expect(wrapper.find(".social-icons-list-icon").exists()).toBe(false);
139
206
  });
140
207
 
141
- it("hides the icon from assistive tech (the link carries the name)", async () => {
142
- const wrapper = await mountSuspended(SocialIconsList, { props: { items: defaultItems } });
143
- expect(wrapper.find(".social-icon").attributes("aria-hidden")).toBe("true");
208
+ it("renders the scoped label slot without showLabel", async () => {
209
+ const wrapper = await mountSuspended(SocialIconsList, {
210
+ props: { items: defaultItems },
211
+ slots: { label: ({ item }: { item: ISocialIcon }) => `@${item.profileId}` },
212
+ });
213
+ mounted.push(wrapper);
214
+ expect(wrapper.find(".social-icons-list-label").text()).toBe("@testprofile");
144
215
  });
145
216
  });
@@ -1,9 +1,15 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`SocialIconsList > renders the correct HTML structure 1`] = `
4
- "<ul class="social-icons-list" aria-label="Social media profiles">
5
- <li class="social-icon-item"><a href="https://www.facebook.com/testprofile" class="social-icon-link" aria-label="Facebook profile (opens in a new tab)" rel="noopener noreferrer" target="_blank"><span class="iconify i-logos:facebook social-icon" aria-hidden="true"></span></a></li>
6
- <li class="social-icon-item"><a href="https://www.instagram.com/testhandle" class="social-icon-link" aria-label="Instagram profile (opens in a new tab)" rel="noopener noreferrer" target="_blank"><span class="iconify i-logos:instagram-icon social-icon" aria-hidden="true"></span></a></li>
7
- <li class="social-icon-item"><a href="https://www.youtube.com/@testchannel" class="social-icon-link" aria-label="YouTube profile (opens in a new tab)" rel="noopener noreferrer" target="_blank"><span class="iconify i-logos:youtube-icon social-icon" aria-hidden="true"></span></a></li>
4
+ "<ul class="social-icons-list" role="list" aria-label="Social media profiles">
5
+ <li class="social-icons-list-item"><a href="https://www.facebook.com/testprofile" class="social-icons-list-link" aria-label="Facebook profile (opens in a new tab)" rel="noopener noreferrer" target="_blank"><span class="social-icons-list-icon-wrapper" aria-hidden="true"><span class="iconify i-logos:facebook social-icons-list-icon" aria-hidden="true"></span></span>
6
+ <!--v-if-->
7
+ </a></li>
8
+ <li class="social-icons-list-item"><a href="https://www.instagram.com/testhandle" class="social-icons-list-link" aria-label="Instagram profile (opens in a new tab)" rel="noopener noreferrer" target="_blank"><span class="social-icons-list-icon-wrapper" aria-hidden="true"><span class="iconify i-logos:instagram-icon social-icons-list-icon" aria-hidden="true"></span></span>
9
+ <!--v-if-->
10
+ </a></li>
11
+ <li class="social-icons-list-item"><a href="https://www.youtube.com/@testchannel" class="social-icons-list-link" aria-label="YouTube profile (opens in a new tab)" rel="noopener noreferrer" target="_blank"><span class="social-icons-list-icon-wrapper" aria-hidden="true"><span class="iconify i-logos:youtube-icon social-icons-list-icon" aria-hidden="true"></span></span>
12
+ <!--v-if-->
13
+ </a></li>
8
14
  </ul>"
9
15
  `;
@@ -71,9 +71,15 @@ Toasts slide in and out over `behavior.revealDuration` (default `550ms`). Under
71
71
  because it shows how long is left.
72
72
 
73
73
  Auto-dismiss pauses while the pointer is over a toast, or while keyboard focus is on it or inside
74
- it, and resumes with the time that was left (WCAG 2.2.2). The toast moves focus to itself when
75
- shown; that only pauses it when the user was using a keyboard (`:focus-visible`), so mouse users
76
- still get auto-dismiss.
74
+ it, and resumes with the time that was left (WCAG 2.2.2). Toasts don't take focus when they
75
+ appear, so the focus ring only shows when a keyboard user tabs to one, and focus on the toast
76
+ itself only pauses it for keyboard users (`:focus-visible`).
77
+
78
+ > Changed 2026-10-16: toasts used to move focus to themselves when shown, which interrupted
79
+ > typing and showed the focus ring when a toast opened before any pointer input. Escape now
80
+ > dismisses a toast from anywhere on the page (the provider dismisses the newest one), unless
81
+ > another handler called `preventDefault()`. No consumer change needed, unless your own code relied
82
+ > on the toast holding focus.
77
83
 
78
84
  ## Global theming
79
85
 
@@ -14,7 +14,6 @@
14
14
  :aria-live="ariaLive"
15
15
  :tabindex="slots.default ? undefined : '0'"
16
16
  :aria-describedby="slots.default ? undefined : 'toast-message-' + toastId"
17
- @keydown.escape="setDismissToast"
18
17
  @pointerenter="isHovered = true"
19
18
  @pointerleave="isHovered = false"
20
19
  @focusin="handleFocusIn"
@@ -48,6 +47,7 @@
48
47
  </template>
49
48
 
50
49
  <script setup lang="ts">
50
+ import { useEventListener } from "@vueuse/core";
51
51
  import AlertContent from "~/components/02.molecules/alert-content/AlertContent.vue";
52
52
  import AlertMaskedContent from "~/components/02.molecules/alert-masked-content/AlertMaskedContent.vue";
53
53
  import type { DisplayToastConfig, ToastSlots } from "~/types/components";
@@ -182,15 +182,6 @@ watch(
182
182
  privateDisplayToast.value = true;
183
183
  transitionalState.value = true;
184
184
 
185
- // Focus management for accessibility when not using custom slots
186
- if (!slots.default) {
187
- await nextTick();
188
- // Wait for animation to start before focusing
189
- setTimeout(() => {
190
- toastElementRef.value?.focus();
191
- }, 100);
192
- }
193
-
194
185
  if (autoDismiss.value) {
195
186
  clearDismissTimer();
196
187
  dismissTimer = createPausableTimeout(setDismissToast, duration.value);
@@ -203,6 +194,15 @@ watch(
203
194
  }
204
195
  );
205
196
 
197
+ // The toast never takes focus (WCAG 4.1.3), so Escape is heard document-wide while it's shown.
198
+ useEventListener(
199
+ () => (import.meta.client ? document : null),
200
+ "keydown",
201
+ (event: KeyboardEvent) => {
202
+ if (event.key === "Escape" && !event.defaultPrevented && transitionalState.value) setDismissToast();
203
+ }
204
+ );
205
+
206
206
  onBeforeRouteLeave(() => {
207
207
  setDismissToast();
208
208
  });
@@ -7,7 +7,6 @@
7
7
  :data-alignment="fullWidth ? 'full-width' : alignment"
8
8
  @before-leave="onBeforeLeave"
9
9
  @after-leave="onAfterLeave"
10
- @after-enter="onAfterEnter"
11
10
  >
12
11
  <div
13
12
  v-for="entry in visibleEntries"
@@ -54,6 +53,7 @@
54
53
  </template>
55
54
 
56
55
  <script setup lang="ts">
56
+ import { useEventListener } from "@vueuse/core";
57
57
  import AlertContent from "~/components/02.molecules/alert-content/AlertContent.vue";
58
58
  import AlertMaskedContent from "~/components/02.molecules/alert-masked-content/AlertMaskedContent.vue";
59
59
  import type {
@@ -187,9 +187,17 @@ const onAfterLeave = () => {
187
187
  }
188
188
  };
189
189
 
190
- const onAfterEnter = (el: Element) => {
191
- (el as HTMLElement).focus();
192
- };
190
+ // Toasts never take focus (WCAG 4.1.3); Escape outside one dismisses the newest.
191
+ useEventListener(
192
+ () => (import.meta.client ? document : null),
193
+ "keydown",
194
+ (event: KeyboardEvent) => {
195
+ if (event.key !== "Escape" || event.defaultPrevented) return;
196
+ if ((event.target as Element | null)?.closest?.(".display-toast-provider-item")) return;
197
+ const newest = visibleEntries.value.at(-1);
198
+ if (newest) handleDismiss(newest.id);
199
+ }
200
+ );
193
201
 
194
202
  onUnmounted(() => {
195
203
  timers.forEach((timer) => timer.clear());
@@ -1,12 +1,56 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { ref, onMounted } from "vue";
2
+ import { ref, computed, watchEffect, onMounted, onUnmounted } from "vue";
3
3
  import StorybookComponent from "../DisplayToast.vue";
4
4
 
5
+ type CanvasKey = "none" | "gradient" | "lake" | "portrait" | "dark-photo" | "stripes" | "page-text";
6
+
7
+ const busyBackdrop = [
8
+ "repeating-linear-gradient(45deg, oklch(100% 0 0 / 0.18) 0 1.2rem, transparent 1.2rem 2.4rem)",
9
+ "linear-gradient(120deg, oklch(70% 0.2 30), oklch(75% 0.18 90) 35%, oklch(65% 0.2 200) 70%, oklch(55% 0.22 300))",
10
+ ].join(", ");
11
+
12
+ const canvases: Record<CanvasKey, string> = {
13
+ none: "",
14
+ gradient: "background: linear-gradient(135deg, oklch(40% 0.15 250), oklch(25% 0.1 300));",
15
+ lake: "background: url('/images/banners/video/lake-banner.jpg') center / cover;",
16
+ portrait: "background: url('/images/page/hero/hero-blonde.jpg') center 30% / cover;",
17
+ "dark-photo": "background: url('/images/page/hero/hero-dark.jpg') center / cover;",
18
+ stripes: `background: ${busyBackdrop};`,
19
+ "page-text": "background: white;",
20
+ };
21
+
22
+ const pageText = Array.from(
23
+ { length: 6 },
24
+ () =>
25
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " +
26
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. " +
27
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
28
+ );
29
+
30
+ /*
31
+ * The toast teleports to <body>, so a fill override on a story wrapper never reaches it. Inject a
32
+ * rule on .display-toast instead: --theme-surface-subtle then resolves against the toast's data-theme.
33
+ */
34
+ function useMaskedFillStyle(fill: () => number) {
35
+ const styleEl = document.createElement("style");
36
+ document.head.appendChild(styleEl);
37
+ watchEffect(() => {
38
+ styleEl.textContent =
39
+ fill() === 80
40
+ ? ""
41
+ : `.display-toast { --alert-masked-content-background: color-mix(in oklab, var(--theme-surface-subtle) ${fill()}%, transparent); }`;
42
+ });
43
+ onUnmounted(() => styleEl.remove());
44
+ }
45
+
5
46
  interface ToastStoryArgs {
6
47
  theme: "info" | "success" | "warning" | "error";
7
48
  position: "top" | "bottom";
8
49
  alignment: "left" | "center" | "right";
9
50
  fullWidth: boolean;
51
+ masked: boolean;
52
+ maskedFill: number;
53
+ canvas: CanvasKey;
10
54
  autoDismiss: boolean;
11
55
  duration: number;
12
56
  revealDuration: number;
@@ -42,6 +86,24 @@ export default {
42
86
  description: "Whether toast spans full width",
43
87
  table: { category: "Appearance" },
44
88
  },
89
+ masked: {
90
+ control: { type: "boolean" },
91
+ description: "Render with AlertMaskedContent (SVG border, translucent fill) instead of AlertContent",
92
+ table: { category: "Appearance" },
93
+ },
94
+ maskedFill: {
95
+ control: { type: "range", min: 0, max: 100, step: 5 },
96
+ description:
97
+ "Story-only: opacity (%) of the masked fill, set via --alert-masked-content-background. 80 is the library default; lower values cost contrast",
98
+ if: { arg: "masked" },
99
+ table: { category: "Story canvas" },
100
+ },
101
+ canvas: {
102
+ control: { type: "select" },
103
+ options: ["none", "gradient", "lake", "portrait", "dark-photo", "stripes", "page-text"],
104
+ description: "Story-only: full-viewport backdrop behind the toast, to show what the masked variant lets through",
105
+ table: { category: "Story canvas" },
106
+ },
45
107
  autoDismiss: {
46
108
  control: { type: "boolean" },
47
109
  description: "Auto dismiss toast after duration",
@@ -80,6 +142,9 @@ export default {
80
142
  position: "top",
81
143
  alignment: "right",
82
144
  fullWidth: false,
145
+ masked: false,
146
+ maskedFill: 80,
147
+ canvas: "none",
83
148
  autoDismiss: false,
84
149
  duration: 3000,
85
150
  revealDuration: 300,
@@ -104,9 +169,21 @@ const Template: StoryFn<ToastStoryArgs> = (args) => ({
104
169
  }, 500);
105
170
  });
106
171
 
107
- return { args, isActive };
172
+ useMaskedFillStyle(() => args.maskedFill);
173
+ const canvasStyle = computed(() => canvases[args.canvas]);
174
+
175
+ return { args, isActive, canvasStyle, pageText };
108
176
  },
109
177
  template: `
178
+ <div
179
+ v-if="args.canvas !== 'none'"
180
+ :style="canvasStyle + ' position: fixed; inset: 0; overflow: hidden; padding: 6rem 2rem 2rem; font-family: sans-serif; font-size: 1.6rem; line-height: 1.5; color: #222;'"
181
+ >
182
+ <template v-if="args.canvas === 'page-text'">
183
+ <h2 style="margin: 0 0 1rem;">Article heading behind the toast</h2>
184
+ <p v-for="(para, i) in pageText" :key="i" style="margin: 0 0 1rem;">{{ para }}</p>
185
+ </template>
186
+ </div>
110
187
  <div style="position: relative; height: 200px; padding: 20px;">
111
188
  <button
112
189
  @click="isActive = true"
@@ -123,6 +200,7 @@ const Template: StoryFn<ToastStoryArgs> = (args) => ({
123
200
  position: args.position,
124
201
  alignment: args.alignment,
125
202
  fullWidth: args.fullWidth,
203
+ masked: args.masked,
126
204
  },
127
205
  behavior: {
128
206
  autoDismiss: args.autoDismiss,
@@ -220,3 +298,123 @@ StressTest.parameters = {
220
298
  },
221
299
  },
222
300
  };
301
+
302
+ export const MaskedOverGradient = Template.bind({});
303
+ MaskedOverGradient.storyName = "Masked: Over Gradient";
304
+ MaskedOverGradient.args = {
305
+ masked: true,
306
+ canvas: "gradient",
307
+ title: "Masked toast",
308
+ description: "AlertMaskedContent: the accent border is an SVG cut-out and the fill is 80% translucent.",
309
+ };
310
+ MaskedOverGradient.parameters = {
311
+ docs: {
312
+ description: {
313
+ story:
314
+ "`appearance.masked: true` swaps AlertContent for AlertMaskedContent. Over a flat gradient the effect is subtle: " +
315
+ "switch `canvas` to a photo or the stripes to see the backdrop through the fill.",
316
+ },
317
+ },
318
+ };
319
+
320
+ export const MaskedOverPhoto = Template.bind({});
321
+ MaskedOverPhoto.storyName = "Masked: Over Photo";
322
+ MaskedOverPhoto.args = {
323
+ theme: "success",
324
+ masked: true,
325
+ canvas: "portrait",
326
+ title: "Booking confirmed",
327
+ description: "We've sent the details to your inbox.",
328
+ customIcon: "akar-icons:check-box",
329
+ };
330
+ MaskedOverPhoto.parameters = {
331
+ docs: {
332
+ description: {
333
+ story:
334
+ "A photo backdrop is where the masked variant earns its place: the image stays visible through the body and around the border. " +
335
+ "Try `lake` and `dark-photo` too, and every theme.",
336
+ },
337
+ },
338
+ };
339
+
340
+ export const MaskedVsOpaque: StoryFn<ToastStoryArgs> = (args) => ({
341
+ components: { StorybookComponent },
342
+ setup() {
343
+ const isActive = ref(false);
344
+ onMounted(() => {
345
+ setTimeout(() => {
346
+ isActive.value = true;
347
+ }, 500);
348
+ });
349
+ useMaskedFillStyle(() => args.maskedFill);
350
+ const canvasStyle = computed(() => canvases[args.canvas]);
351
+ const config = (masked: boolean, alignment: "left" | "right") => ({
352
+ appearance: { theme: args.theme, position: args.position, alignment, masked },
353
+ behavior: { autoDismiss: false, revealDuration: args.revealDuration },
354
+ content: { title: masked ? "AlertMaskedContent" : "AlertContent", description: args.description },
355
+ });
356
+ return { args, isActive, canvasStyle, config };
357
+ },
358
+ template: `
359
+ <div :style="canvasStyle + ' position: fixed; inset: 0;'"></div>
360
+ <div style="position: relative; padding: 20px;">
361
+ <button
362
+ @click="isActive = true"
363
+ style="padding: 8px 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer;"
364
+ >
365
+ Trigger Toasts
366
+ </button>
367
+ </div>
368
+ <StorybookComponent v-model="isActive" :config="config(false, 'left')" />
369
+ <StorybookComponent v-model="isActive" :config="config(true, 'right')" />
370
+ `,
371
+ });
372
+ MaskedVsOpaque.storyName = "Masked: Side by Side with Opaque";
373
+ MaskedVsOpaque.args = {
374
+ masked: true,
375
+ canvas: "lake",
376
+ description: "Same theme, same backdrop: opaque on the left, masked on the right.",
377
+ };
378
+ MaskedVsOpaque.argTypes = {
379
+ masked: { table: { disable: true } },
380
+ alignment: { table: { disable: true } },
381
+ fullWidth: { table: { disable: true } },
382
+ title: { table: { disable: true } },
383
+ customIcon: { table: { disable: true } },
384
+ autoDismiss: { table: { disable: true } },
385
+ duration: { table: { disable: true } },
386
+ };
387
+ MaskedVsOpaque.parameters = {
388
+ docs: {
389
+ description: {
390
+ story:
391
+ "Two toasts over one backdrop: AlertContent (left) and AlertMaskedContent (right). Change `canvas` and `theme` to compare, " +
392
+ "and drag `maskedFill` to see how much backdrop the masked one lets through.",
393
+ },
394
+ },
395
+ };
396
+
397
+ export const MaskedLowContrast = Template.bind({});
398
+ MaskedLowContrast.storyName = "Masked: Low Contrast Risk";
399
+ MaskedLowContrast.args = {
400
+ theme: "info",
401
+ masked: true,
402
+ maskedFill: 35,
403
+ canvas: "page-text",
404
+ alignment: "center",
405
+ title: "Hard to read",
406
+ description: "Page text shows through a 35% fill and fights with the toast's own text.",
407
+ };
408
+ MaskedLowContrast.parameters = {
409
+ docs: {
410
+ description: {
411
+ story:
412
+ "What can go wrong. The default 80% fill keeps `--theme-text` readable over most backdrops, but contrast depends on what's behind the toast, " +
413
+ "which you don't control once it's fixed to the viewport. Here the fill is lowered to 35% over body copy, so letterforms from the page " +
414
+ "show through and compete with the message. Other traps to try: `stripes` or `portrait` with a low fill (busy detail behind small text), " +
415
+ "and the `info` theme over `gradient` (the 1px accent border nearly vanishes against a backdrop of a similar hue). " +
416
+ "Drag `maskedFill` back up to 80 to see the default recover. If you lower `--alert-masked-content-background`, test it over your real imagery and content, " +
417
+ "and prefer the opaque variant for error and warning toasts that must be read.",
418
+ },
419
+ },
420
+ };