srcdev-nuxt-components 9.6.4 → 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 +14 -2
- package/.claude/skills/components/display-toast.md +6 -3
- package/.claude/skills/components/progress-tracker.md +140 -0
- package/.claude/skills/components/social-icons-list.md +32 -5
- package/.claude/skills/components/stepper-list.md +10 -3
- package/.claude/skills/components/tabbed-content.md +4 -0
- package/.claude/skills/css-dynamic-border-radius.md +114 -0
- package/.claude/skills/index.md +4 -2
- package/.claude/skills/storybook-add-story.md +12 -0
- package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
- package/.vscode/srcdev-component-progress-tracker.code-snippets +45 -0
- package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
- package/.vscode/srcdev-component-stepper-list.code-snippets +3 -2
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -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/01.atoms/tabbed-content/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +2 -2
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +26 -0
- package/app/components/02.molecules/contact-section/tests/__snapshots__/ContactSection.spec.ts.snap +28 -28
- package/app/components/02.molecules/progress-tracker/CONSUMER-STYLING.md +116 -0
- package/app/components/02.molecules/progress-tracker/ProgressTracker.vue +275 -0
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +424 -0
- package/app/components/02.molecules/progress-tracker/tests/ProgressTracker.spec.ts +253 -0
- package/app/components/02.molecules/progress-tracker/tests/__snapshots__/ProgressTracker.spec.ts.snap +46 -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/stepper-list/CONSUMER-STYLING.md +42 -7
- package/app/components/02.molecules/stepper-list/StepperList.vue +42 -19
- package/app/components/02.molecules/stepper-list/stories/StepperList.stories.ts +288 -2
- package/app/components/02.molecules/stepper-list/tests/StepperList.spec.ts +161 -34
- package/app/components/02.molecules/stepper-list/tests/__snapshots__/StepperList.spec.ts.snap +40 -40
- 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/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +7 -7
- package/app/types/components/index.ts +1 -0
- package/app/types/components/progress-tracker.d.ts +6 -0
- package/app/types/components/social-icons-list.d.ts +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SocialIconsList recipes, as a consumer would write them: unlayered, on a class passed through
|
|
3
|
+
* style-class-passthrough. Tokens that read --social-icons-list-brand-colour are set on
|
|
4
|
+
* .social-icons-list-item, the element that defines it (a var() in a custom property resolves where
|
|
5
|
+
* that property is declared, so setting them on the root would find no brand colour).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* 2a: mono tiles, brand colour on hover */
|
|
9
|
+
.demo-social-mono-tiles {
|
|
10
|
+
--social-icons-list-gap: 1rem;
|
|
11
|
+
--social-icons-list-icon-size: 2rem;
|
|
12
|
+
--social-icons-list-icon-colour: #16171b;
|
|
13
|
+
--social-icons-list-link-padding: 1.2rem;
|
|
14
|
+
--social-icons-list-link-radius: 1.2rem;
|
|
15
|
+
--social-icons-list-link-background: #f1f2f5;
|
|
16
|
+
--social-icons-list-link-background-hover: #e6e8ee;
|
|
17
|
+
--social-icons-list-hover-scale: 1.04;
|
|
18
|
+
--social-icons-list-hover-opacity: 1;
|
|
19
|
+
|
|
20
|
+
.social-icons-list-item {
|
|
21
|
+
--social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* 2b: dark dock with glow */
|
|
26
|
+
.demo-social-dark-dock {
|
|
27
|
+
inline-size: fit-content;
|
|
28
|
+
padding: 0.8rem;
|
|
29
|
+
border: 1px solid #34353b;
|
|
30
|
+
border-radius: 2.2rem;
|
|
31
|
+
background: #232428;
|
|
32
|
+
--social-icons-list-gap: 0.8rem;
|
|
33
|
+
--social-icons-list-icon-size: 2rem;
|
|
34
|
+
--social-icons-list-icon-colour: #ffffff;
|
|
35
|
+
--social-icons-list-link-padding: 1.2rem;
|
|
36
|
+
--social-icons-list-link-radius: 1.4rem;
|
|
37
|
+
--social-icons-list-link-background: #1a1b1f;
|
|
38
|
+
--social-icons-list-hover-scale: 1;
|
|
39
|
+
--social-icons-list-hover-opacity: 1;
|
|
40
|
+
|
|
41
|
+
.social-icons-list-item {
|
|
42
|
+
--social-icons-list-link-shadow-hover:
|
|
43
|
+
0 0 0 1px color-mix(in oklab, var(--social-icons-list-brand-colour) 60%, transparent),
|
|
44
|
+
0 0 1.6rem color-mix(in oklab, var(--social-icons-list-brand-colour) 55%, transparent);
|
|
45
|
+
--social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* 2c: labelled pills (show-label) */
|
|
50
|
+
.demo-social-pills {
|
|
51
|
+
justify-content: center;
|
|
52
|
+
--social-icons-list-gap: 1rem;
|
|
53
|
+
--social-icons-list-icon-size: 1.6rem;
|
|
54
|
+
--social-icons-list-icon-colour: #ffffff;
|
|
55
|
+
--social-icons-list-link-padding: 0.4rem 1.4rem 0.4rem 0.4rem;
|
|
56
|
+
--social-icons-list-link-gap: 0.8rem;
|
|
57
|
+
--social-icons-list-link-border: 1.5px solid #16171b;
|
|
58
|
+
--social-icons-list-link-radius: 99rem;
|
|
59
|
+
--social-icons-list-link-background: #ffffff;
|
|
60
|
+
--social-icons-list-label-colour: #16171b;
|
|
61
|
+
--social-icons-list-icon-wrapper-padding: 0.6rem;
|
|
62
|
+
--social-icons-list-icon-wrapper-radius: 50%;
|
|
63
|
+
--social-icons-list-hover-scale: 1.03;
|
|
64
|
+
--social-icons-list-hover-opacity: 1;
|
|
65
|
+
|
|
66
|
+
.social-icons-list-item {
|
|
67
|
+
--social-icons-list-icon-wrapper-background: var(--social-icons-list-brand-colour);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* 2d: overlapping stack, fans out on hover or keyboard focus */
|
|
72
|
+
.demo-social-stack {
|
|
73
|
+
--social-icons-list-gap: 0;
|
|
74
|
+
--social-icons-list-icon-size: 1.8rem;
|
|
75
|
+
--social-icons-list-icon-colour: #ffffff;
|
|
76
|
+
--social-icons-list-icon-wrapper-padding: 1rem;
|
|
77
|
+
--social-icons-list-icon-wrapper-radius: 50%;
|
|
78
|
+
--social-icons-list-icon-wrapper-border: 2px solid #ffffff;
|
|
79
|
+
--social-icons-list-link-radius: 50%;
|
|
80
|
+
--social-icons-list-hover-scale: 1.08;
|
|
81
|
+
--social-icons-list-hover-opacity: 1;
|
|
82
|
+
|
|
83
|
+
.social-icons-list-item {
|
|
84
|
+
--social-icons-list-icon-wrapper-background: var(--social-icons-list-brand-colour);
|
|
85
|
+
|
|
86
|
+
& + .social-icons-list-item {
|
|
87
|
+
margin-inline-start: -1.2rem;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&:hover,
|
|
91
|
+
&:focus-within {
|
|
92
|
+
z-index: 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
&:hover,
|
|
97
|
+
&:focus-within {
|
|
98
|
+
.social-icons-list-item + .social-icons-list-item {
|
|
99
|
+
margin-inline-start: 0.4rem;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
104
|
+
.social-icons-list-item {
|
|
105
|
+
transition: margin-inline-start 250ms ease-out;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -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
|
`;
|
|
@@ -15,12 +15,14 @@ file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
|
|
|
15
15
|
| `--stepper-list-counter-font-size` | `1.4rem` | Counter number font size |
|
|
16
16
|
| `--stepper-list-counter-font-weight` | `600` | Counter number font weight |
|
|
17
17
|
| `--stepper-list-connector-width` | `0.2rem` | Connector line thickness |
|
|
18
|
+
| `--stepper-list-connector-gap` | `0` | Space between each end of the connector line and the indicators it joins. A gap larger than half the space between two indicators hides that line |
|
|
18
19
|
|
|
19
20
|
### Colours
|
|
20
21
|
|
|
21
22
|
| Token | Default | Controls |
|
|
22
23
|
|---|---|---|
|
|
23
24
|
| `--stepper-list-connector-color` | `currentColor` | Connector line colour |
|
|
25
|
+
| `--stepper-list-connector-image` | `none` | Image painted over the connector colour, e.g. a `repeating-linear-gradient()` for a dashed or dotted line (set the colour to `transparent` underneath) |
|
|
24
26
|
| `--stepper-list-icon-color` | `currentColor` | Icon colour for custom `indicator-icon` elements |
|
|
25
27
|
|
|
26
28
|
### Circle indicator
|
|
@@ -58,8 +60,8 @@ The defaults above are the component's own fallbacks. The library's default them
|
|
|
58
60
|
> reads, so custom indicator icons fell back to `currentColor`. It now sets
|
|
59
61
|
> `--stepper-list-icon-color`.
|
|
60
62
|
|
|
61
|
-
Private (not public API): `--
|
|
62
|
-
`--_connector-height`, which script measures and sets inline on each `<li>`.
|
|
63
|
+
Private (not public API): `--_counter-size` and `--_padding-block`, plus `--_connector-left`,
|
|
64
|
+
`--_connector-top` and `--_connector-height`, which script measures and sets inline on each `<li>`.
|
|
63
65
|
|
|
64
66
|
---
|
|
65
67
|
|
|
@@ -72,8 +74,14 @@ Private (not public API): `--_gap`, `--_counter-size` and `--_padding-block`, pl
|
|
|
72
74
|
| `.indicator-top`, `.indicator-center` | `<li>` | `indicator-alignment` |
|
|
73
75
|
| `.indicator-disc`, `.indicator-square`, `.indicator-circle` | `<li>` | `indicator-variant` |
|
|
74
76
|
|
|
75
|
-
Inner classes: `.stepper-
|
|
76
|
-
`.stepper-list__indicator-custom` (wraps a custom `indicator-{n}` slot)
|
|
77
|
+
Inner classes: `.stepper-list-item` (each `<li>`), `.stepper-list__indicator-counter`
|
|
78
|
+
(auto-numbered bubble) or `.stepper-list__indicator-custom` (wraps a custom `indicator-{n}` slot),
|
|
79
|
+
and `.stepper-list-content` (wraps the `item-{n}` slot).
|
|
80
|
+
|
|
81
|
+
> Changed 2026-10-15: `<li>` gained `.stepper-list-item` and the content wrapper gained
|
|
82
|
+
> `.stepper-list-content` (it had no class). The content column is now `minmax(0, 1fr)` with
|
|
83
|
+
> `overflow-wrap: anywhere`, so an unbroken string or long URL wraps instead of widening the list.
|
|
84
|
+
> The root carries `role="list"`, since `list-style: none` drops list semantics in Safari.
|
|
77
85
|
|
|
78
86
|
---
|
|
79
87
|
|
|
@@ -156,6 +164,29 @@ visual style, pass a modifier class:
|
|
|
156
164
|
|
|
157
165
|
---
|
|
158
166
|
|
|
167
|
+
## Recipe: per-step states and dashed connectors
|
|
168
|
+
|
|
169
|
+
The component has no "done / current / upcoming" prop. Put a state class on the content you pass
|
|
170
|
+
to each `#indicator-{n}` slot, then set tokens on the matching `.stepper-list-item` with `:has()`.
|
|
171
|
+
Each connector belongs to the item above it, so it takes that item's state.
|
|
172
|
+
|
|
173
|
+
```css
|
|
174
|
+
.onboarding-steps {
|
|
175
|
+
--stepper-list-connector-color: transparent;
|
|
176
|
+
--stepper-list-connector-image: repeating-linear-gradient(to bottom, #3a3d45 0 0.2rem, transparent 0.2rem 0.6rem);
|
|
177
|
+
|
|
178
|
+
.stepper-list-item:has(.step-marker.is-done) {
|
|
179
|
+
--stepper-list-connector-image: repeating-linear-gradient(to bottom, #00c8d7 0 0.4rem, transparent 0.4rem 0.7rem);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Shapes such as diamonds go in the indicator slot (a rotated square). The connector is centred on
|
|
185
|
+
whatever the slot renders, so it needs no adjustment. The "Timeline (styled demo)" story shows the
|
|
186
|
+
full pattern.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
159
190
|
## Class passthrough
|
|
160
191
|
|
|
161
192
|
`style-class-passthrough` adds classes to the root `<ul>`/`<ol>`. Tokens set on a passthrough class
|
|
@@ -167,11 +198,15 @@ land for the whole list. Reactive after mount.
|
|
|
167
198
|
|
|
168
199
|
- `--stepper-list-counter-size` takes precedence over the `indicatorSize` prop. Use the prop
|
|
169
200
|
for programmatic/template control; use the CSS token for pure-CSS theme overrides.
|
|
170
|
-
- The connector line is positioned by JS
|
|
171
|
-
`--_connector-top` and `--_connector-height
|
|
201
|
+
- The connector line is positioned by JS on each `<li>` as `--_connector-left`,
|
|
202
|
+
`--_connector-top` and `--_connector-height`, centred on the measured indicator, so it lines up
|
|
203
|
+
under a custom indicator wider than `--stepper-list-counter-size`. These are internal — do not
|
|
204
|
+
set them. They are re-measured when the list or any item resizes, and when a `class` or the
|
|
205
|
+
content changes inside the list, so per-step state styling (the recipe above) keeps the lines
|
|
206
|
+
in place. A change made only through an inline `style` attribute isn't seen; toggle a class
|
|
207
|
+
instead.
|
|
172
208
|
- `--stepper-list-connector-color` defaults to `currentColor`, inheriting from the nearest
|
|
173
209
|
ancestor's text colour. Set it explicitly when that behaviour is undesirable.
|
|
174
210
|
- All border tokens (`--stepper-list-counter-*-border`) control the **colour** only — the
|
|
175
211
|
border is always `2px solid`. Override the border shorthand directly on `.stepper-list li
|
|
176
212
|
.stepper-list__indicator-counter::before` if you need a different width.
|
|
177
|
-
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
<component
|
|
3
3
|
:is="tag"
|
|
4
4
|
ref="listEl"
|
|
5
|
+
role="list"
|
|
5
6
|
class="stepper-list"
|
|
6
7
|
:class="[elementClasses, { 'has-connectors': props.connected }]"
|
|
7
8
|
>
|
|
8
9
|
<li
|
|
9
|
-
v-for="index in
|
|
10
|
+
v-for="index in itemTotal"
|
|
10
11
|
:key="index"
|
|
11
12
|
:class="[
|
|
13
|
+
'stepper-list-item',
|
|
12
14
|
{ 'has-indicator': $slots[`indicator-${index - 1}`] },
|
|
13
15
|
`indicator-${props.indicatorAlignment}`,
|
|
14
16
|
`indicator-${props.indicatorVariant}`,
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
>
|
|
20
22
|
<slot :name="`indicator-${index - 1}`"></slot>
|
|
21
23
|
</div>
|
|
22
|
-
<div>
|
|
24
|
+
<div class="stepper-list-content">
|
|
23
25
|
<slot :name="`item-${index - 1}`"></slot>
|
|
24
26
|
</div>
|
|
25
27
|
</li>
|
|
@@ -53,6 +55,8 @@ watch(
|
|
|
53
55
|
() => resetElementClasses(props.styleClassPassthrough)
|
|
54
56
|
);
|
|
55
57
|
|
|
58
|
+
const itemTotal = computed(() => Math.max(0, Math.floor(Number(props.itemCount)) || 0));
|
|
59
|
+
|
|
56
60
|
const listEl = ref<HTMLElement | null>(null);
|
|
57
61
|
let resizeObserver: ResizeObserver | null = null;
|
|
58
62
|
|
|
@@ -76,27 +80,50 @@ function updateConnectors() {
|
|
|
76
80
|
const indicatorRect = indicator.getBoundingClientRect();
|
|
77
81
|
const nextIndicatorRect = nextIndicator.getBoundingClientRect();
|
|
78
82
|
|
|
79
|
-
li.style.setProperty("--_connector-
|
|
83
|
+
li.style.setProperty("--_connector-left", `${indicatorRect.left - liRect.left + indicatorRect.width / 2}px`);
|
|
84
|
+
li.style.setProperty("--_connector-top",`${indicatorRect.bottom - liRect.top}px`);
|
|
80
85
|
li.style.setProperty("--_connector-height", `${Math.max(0, nextIndicatorRect.top - indicatorRect.bottom)}px`);
|
|
81
86
|
});
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
let mutationObserver: MutationObserver | null = null;
|
|
90
|
+
|
|
91
|
+
// Items can resize or restyle without the list changing size (e.g. a card moving from one step to the next).
|
|
92
|
+
function observeItems() {
|
|
93
|
+
if (!resizeObserver || !listEl.value) return;
|
|
94
|
+
resizeObserver.disconnect();
|
|
95
|
+
resizeObserver.observe(listEl.value);
|
|
96
|
+
listEl.value.querySelectorAll(":scope > li").forEach((li) => resizeObserver?.observe(li));
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
onMounted(() => {
|
|
85
100
|
updateConnectors();
|
|
86
|
-
if (
|
|
101
|
+
if (!listEl.value) return;
|
|
102
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
87
103
|
resizeObserver = new ResizeObserver(updateConnectors);
|
|
88
|
-
|
|
104
|
+
observeItems();
|
|
105
|
+
}
|
|
106
|
+
if (typeof MutationObserver !== "undefined") {
|
|
107
|
+
mutationObserver = new MutationObserver(updateConnectors);
|
|
108
|
+
mutationObserver.observe(listEl.value, {
|
|
109
|
+
subtree: true,
|
|
110
|
+
childList: true,
|
|
111
|
+
attributes: true,
|
|
112
|
+
attributeFilter: ["class"],
|
|
113
|
+
});
|
|
89
114
|
}
|
|
90
115
|
});
|
|
91
116
|
|
|
92
117
|
onBeforeUnmount(() => {
|
|
93
118
|
resizeObserver?.disconnect();
|
|
119
|
+
mutationObserver?.disconnect();
|
|
94
120
|
});
|
|
95
121
|
|
|
96
122
|
watch(
|
|
97
|
-
() => [
|
|
123
|
+
() => [itemTotal.value, props.connected, props.indicatorAlignment],
|
|
98
124
|
async () => {
|
|
99
125
|
await nextTick();
|
|
126
|
+
observeItems();
|
|
100
127
|
updateConnectors();
|
|
101
128
|
}
|
|
102
129
|
);
|
|
@@ -107,7 +134,6 @@ watch(
|
|
|
107
134
|
.stepper-list {
|
|
108
135
|
/* Layout */
|
|
109
136
|
--_padding-block: var(--stepper-list-padding-block, 1.2rem);
|
|
110
|
-
--_gap: var(--stepper-list-gap, 2.2rem);
|
|
111
137
|
--_counter-size: var(--stepper-list-counter-size, v-bind(indicatorSize));
|
|
112
138
|
|
|
113
139
|
list-style: none;
|
|
@@ -116,8 +142,8 @@ watch(
|
|
|
116
142
|
|
|
117
143
|
li {
|
|
118
144
|
display: grid;
|
|
119
|
-
grid-template-columns: auto 1fr;
|
|
120
|
-
gap: var(--
|
|
145
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
146
|
+
gap: var(--stepper-list-gap, 2.2rem);
|
|
121
147
|
counter-increment: stepper-list;
|
|
122
148
|
padding-inline-start: 0rem;
|
|
123
149
|
padding-block: var(--_padding-block);
|
|
@@ -163,13 +189,8 @@ watch(
|
|
|
163
189
|
border-radius: var(--stepper-list-counter-square-border-radius, 0.25rem);
|
|
164
190
|
}
|
|
165
191
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
.stepper-list__indicator-custom,
|
|
171
|
-
.stepper-list__indicator-counter {
|
|
172
|
-
anchor-name: --indicator-bubble;
|
|
192
|
+
.stepper-list-content {
|
|
193
|
+
overflow-wrap: anywhere;
|
|
173
194
|
}
|
|
174
195
|
|
|
175
196
|
.stepper-list__indicator-custom {
|
|
@@ -189,15 +210,17 @@ watch(
|
|
|
189
210
|
.stepper-list.has-connectors li:not(:last-child)::after {
|
|
190
211
|
content: "";
|
|
191
212
|
position: absolute;
|
|
192
|
-
left: calc(var(--_counter-size) / 2);
|
|
213
|
+
left: var(--_connector-left, calc(var(--_counter-size) / 2));
|
|
193
214
|
transform: translateX(-50%);
|
|
194
|
-
/* JS sets --_connector-top and --_connector-height from measured indicator
|
|
195
|
-
The fallback values handle the indicator-top case before JS runs. */
|
|
215
|
+
/* JS sets --_connector-left, --_connector-top and --_connector-height from measured indicator
|
|
216
|
+
positions. The fallback values handle the indicator-top case before JS runs. */
|
|
196
217
|
top: var(--_connector-top, calc(var(--_padding-block) + var(--_counter-size)));
|
|
197
218
|
height: var(--_connector-height, auto);
|
|
198
219
|
bottom: calc(-1 * var(--_padding-block));
|
|
199
220
|
width: var(--stepper-list-connector-width, 0.2rem);
|
|
221
|
+
clip-path: inset(var(--stepper-list-connector-gap, 0) 0);
|
|
200
222
|
background-color: var(--stepper-list-connector-color, currentColor);
|
|
223
|
+
background-image: var(--stepper-list-connector-image, none);
|
|
201
224
|
}
|
|
202
225
|
}
|
|
203
226
|
</style>
|