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.
Files changed (49) hide show
  1. package/.claude/rules/consumer/a11y-no-focus-steal.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +14 -2
  3. package/.claude/skills/components/display-toast.md +6 -3
  4. package/.claude/skills/components/progress-tracker.md +140 -0
  5. package/.claude/skills/components/social-icons-list.md +32 -5
  6. package/.claude/skills/components/stepper-list.md +10 -3
  7. package/.claude/skills/components/tabbed-content.md +4 -0
  8. package/.claude/skills/css-dynamic-border-radius.md +114 -0
  9. package/.claude/skills/index.md +4 -2
  10. package/.claude/skills/storybook-add-story.md +12 -0
  11. package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
  12. package/.vscode/srcdev-component-progress-tracker.code-snippets +45 -0
  13. package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
  14. package/.vscode/srcdev-component-stepper-list.code-snippets +3 -2
  15. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -0
  16. package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
  17. package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
  18. package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
  19. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +5 -1
  20. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +2 -2
  21. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +26 -0
  22. package/app/components/02.molecules/contact-section/tests/__snapshots__/ContactSection.spec.ts.snap +28 -28
  23. package/app/components/02.molecules/progress-tracker/CONSUMER-STYLING.md +116 -0
  24. package/app/components/02.molecules/progress-tracker/ProgressTracker.vue +275 -0
  25. package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +424 -0
  26. package/app/components/02.molecules/progress-tracker/tests/ProgressTracker.spec.ts +253 -0
  27. package/app/components/02.molecules/progress-tracker/tests/__snapshots__/ProgressTracker.spec.ts.snap +46 -0
  28. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
  29. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
  30. package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
  31. package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
  32. package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
  33. package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
  34. package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +42 -7
  35. package/app/components/02.molecules/stepper-list/StepperList.vue +42 -19
  36. package/app/components/02.molecules/stepper-list/stories/StepperList.stories.ts +288 -2
  37. package/app/components/02.molecules/stepper-list/tests/StepperList.spec.ts +161 -34
  38. package/app/components/02.molecules/stepper-list/tests/__snapshots__/StepperList.spec.ts.snap +40 -40
  39. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
  40. package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
  41. package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
  42. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
  43. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
  44. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
  45. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +7 -7
  46. package/app/types/components/index.ts +1 -0
  47. package/app/types/components/progress-tracker.d.ts +6 -0
  48. package/app/types/components/social-icons-list.d.ts +9 -0
  49. 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 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
  `;
@@ -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): `--_gap`, `--_counter-size` and `--_padding-block`, plus `--_connector-top` and
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-list__indicator-counter` (auto-numbered bubble) or
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 (`ResizeObserver`) on each `<li>` as
171
- `--_connector-top` and `--_connector-height`. These are internal — do not set them.
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 itemCount"
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-top", `${indicatorRect.bottom - liRect.top}px`);
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 (typeof ResizeObserver !== "undefined" && listEl.value) {
101
+ if (!listEl.value) return;
102
+ if (typeof ResizeObserver !== "undefined") {
87
103
  resizeObserver = new ResizeObserver(updateConnectors);
88
- resizeObserver.observe(listEl.value);
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
- () => [props.itemCount, props.connected, props.indicatorAlignment],
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(--_gap);
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
- &.has-indicator::before {
167
- display: none;
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 positions.
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>