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.
- package/.claude/rules/consumer/a11y-no-focus-steal.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +5 -1
- package/.claude/skills/components/display-toast.md +6 -3
- package/.claude/skills/components/social-icons-list.md +32 -5
- package/.claude/skills/css-dynamic-border-radius.md +114 -0
- package/.claude/skills/index.md +2 -1
- package/.claude/skills/storybook-add-story.md +6 -0
- package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
- package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
- package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
- package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
- package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
- package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
- package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
- package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
- package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
- package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
- package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
- package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
- package/app/types/components/social-icons-list.d.ts +9 -0
- 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
|
|
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
|
|
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
|
|
44
|
-
|
|
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
|
|
51
|
-
|
|
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
|
|
61
|
-
|
|
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
|
|
71
|
-
|
|
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
|
|
81
|
-
|
|
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
|
|
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
|
|
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
|
|
105
|
-
|
|
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
|
|
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
|
|
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
|
|
129
|
-
|
|
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:
|
|
199
|
+
props: { items: defaultItems },
|
|
200
|
+
slots: { icon: ({ item }: { item: ISocialIcon }) => h("b", { class: "custom-icon" }, item.networkName[0]) },
|
|
137
201
|
});
|
|
138
|
-
|
|
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("
|
|
142
|
-
const wrapper = await mountSuspended(SocialIconsList, {
|
|
143
|
-
|
|
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-
|
|
6
|
-
|
|
7
|
-
|
|
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).
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
+
};
|