srcdev-nuxt-components 9.6.13 → 9.6.15

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 (74) hide show
  1. package/.claude/rules/consumer/story-register-components.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/display-qr-code.md +22 -4
  4. package/.claude/skills/components/eyebrow-text.md +9 -5
  5. package/.claude/skills/components/header-block.md +13 -5
  6. package/.claude/skills/components/hero-text.md +13 -9
  7. package/.claude/skills/components/link-text.md +8 -5
  8. package/.claude/skills/components/pop-over.md +17 -3
  9. package/.claude/skills/components/sliding-toggle.md +108 -0
  10. package/.claude/skills/components/tabbed-content.md +8 -3
  11. package/.claude/skills/composable-sliding-indicator.md +60 -0
  12. package/.claude/skills/index.md +2 -0
  13. package/.claude/skills/storybook-add-story.md +22 -2
  14. package/.claude/skills/testing-add-playwright.md +12 -7
  15. package/.claude/skills/testing-add-unit-test.md +25 -0
  16. package/.claude/skills/theming-component-token-pattern.md +18 -0
  17. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  18. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  19. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  20. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  21. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  22. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  24. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  25. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  26. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  27. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  28. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  29. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  30. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  31. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  32. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  33. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  34. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  35. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  36. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  37. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  41. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  42. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  43. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  44. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  45. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  46. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  47. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  48. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  49. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  50. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  51. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  52. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  53. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  54. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  55. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  56. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  57. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  58. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  59. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  60. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  61. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  62. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  63. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  64. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  65. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  66. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  67. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  68. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  69. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  70. package/app/composables/useSlidingIndicator.ts +56 -0
  71. package/app/composables/useTabs.ts +4 -31
  72. package/app/types/components/index.ts +1 -0
  73. package/app/types/components/sliding-toggle.d.ts +5 -0
  74. package/package.json +1 -1
@@ -7,134 +7,131 @@ const meta: Meta<typeof LinkText> = {
7
7
  argTypes: {
8
8
  to: {
9
9
  control: "text",
10
- description: "Link destination (internal path or external URL)",
10
+ description: "Link destination (internal path or external URL).",
11
+ table: { category: "Basic" },
11
12
  },
12
13
  linkText: {
13
14
  control: "text",
14
- description: "Visible link label",
15
+ description: "Visible link label. It should name the destination, so it's never clamped.",
16
+ table: { category: "Content" },
15
17
  },
16
18
  external: {
17
19
  control: "boolean",
18
- description: "Force external link handling",
19
- defaultValue: false,
20
+ description: "Force external link handling on an internal-looking path.",
21
+ table: { category: "Basic" },
20
22
  },
21
23
  target: {
22
24
  control: { type: "select" },
23
25
  options: [undefined, "_blank", "_self"],
24
- description: "Link target attribute",
25
- },
26
- styleClassPassthrough: {
27
- control: "object",
28
- description: "Additional classes",
29
- defaultValue: [],
26
+ description: "Link `target`. NuxtLink adds `rel=\"noopener noreferrer\"` for an external `_blank`.",
27
+ table: { category: "Basic" },
30
28
  },
29
+ styleClassPassthrough: { table: { disable: true } },
31
30
  },
32
- };
33
-
34
- export default meta;
35
- type Story = StoryObj<typeof LinkText>;
36
-
37
- export const Default: Story = {
38
31
  args: {
39
32
  to: "/",
40
33
  linkText: "Read More",
41
34
  external: false,
42
- styleClassPassthrough: [],
43
35
  },
44
- render: (args) => ({
45
- components: { LinkText },
46
- setup() {
47
- return { args };
36
+ parameters: {
37
+ docs: {
38
+ description: {
39
+ component:
40
+ "A `NuxtLink` styled as a standalone text link. `to` and `linkText` are required; `left` and `right` slots take icons, which stay centred on the label. Colours, underline and gap come from `--link-text-*` tokens.",
41
+ },
48
42
  },
49
- template: `<LinkText v-bind="args" />`,
50
- }),
51
- };
52
-
53
- export const WithLeftIcon: Story = {
54
- args: {
55
- to: "/",
56
- linkText: "Go Back",
57
- styleClassPassthrough: [],
58
43
  },
59
- render: (args) => ({
60
- components: { LinkText },
61
- setup() {
62
- return { args };
63
- },
64
- template: `
65
- <LinkText v-bind="args">
66
- <template #left>
67
- <Icon name="lucide:arrow-left" />
68
- </template>
69
- </LinkText>
70
- `,
71
- }),
72
44
  };
73
45
 
74
- export const WithRightIcon: Story = {
75
- args: {
76
- to: "/",
77
- linkText: "Learn More",
78
- styleClassPassthrough: [],
79
- },
80
- render: (args) => ({
81
- components: { LinkText },
82
- setup() {
83
- return { args };
84
- },
85
- template: `
86
- <LinkText v-bind="args">
87
- <template #right>
88
- <Icon name="lucide:arrow-right" />
89
- </template>
90
- </LinkText>
91
- `,
92
- }),
93
- };
46
+ export default meta;
47
+ type Story = StoryObj<typeof LinkText>;
94
48
 
95
- export const WithBothIcons: Story = {
96
- args: {
97
- to: "/",
98
- linkText: "Explore",
99
- styleClassPassthrough: [],
100
- },
101
- render: (args) => ({
49
+ const withIcons = (left: string | null, right: string | null) => `
50
+ <LinkText v-bind="args">
51
+ ${left ? `<template #left><Icon name="${left}" /></template>` : ""}
52
+ ${right ? `<template #right><Icon name="${right}" /></template>` : ""}
53
+ </LinkText>
54
+ `;
55
+
56
+ const story = (template: string, description: string, args: Story["args"] = {}): Story => ({
57
+ args,
58
+ render: (storyArgs) => ({
102
59
  components: { LinkText },
103
60
  setup() {
104
- return { args };
61
+ return { args: storyArgs };
105
62
  },
106
- template: `
107
- <LinkText v-bind="args">
108
- <template #left>
109
- <Icon name="lucide:sparkles" />
110
- </template>
111
- <template #right>
112
- <Icon name="lucide:arrow-right" />
113
- </template>
114
- </LinkText>
115
- `,
63
+ template,
116
64
  }),
117
- };
65
+ parameters: { docs: { description: { story: description } } },
66
+ });
118
67
 
119
- export const ExternalLink: Story = {
120
- args: {
121
- to: "https://nuxt.com",
122
- linkText: "Nuxt Docs",
123
- external: true,
124
- target: "_blank",
125
- styleClassPassthrough: [],
126
- },
127
- render: (args) => ({
68
+ export const Default = story(withIcons(null, null), "A plain link. Use the Controls panel to change the destination, label and target.");
69
+
70
+ export const WithLeftIcon = story(withIcons("lucide:arrow-left", null), "A `left` slot icon for a back or previous link.", {
71
+ linkText: "Go Back",
72
+ });
73
+
74
+ export const WithRightIcon = story(withIcons(null, "lucide:arrow-right"), "A `right` slot icon for a forward or more link.", {
75
+ linkText: "Learn More",
76
+ });
77
+
78
+ export const WithBothIcons = story(withIcons("lucide:sparkles", "lucide:arrow-right"), "Both slots. Left icon, label, then right icon.", {
79
+ linkText: "Explore",
80
+ });
81
+
82
+ export const ExternalLink = story(
83
+ withIcons(null, "lucide:external-link"),
84
+ "An external link opening in a new tab, with an icon that says so.",
85
+ { to: "https://nuxt.com", linkText: "Nuxt Docs", external: true, target: "_blank" }
86
+ );
87
+
88
+ export const StressTest: Story = {
89
+ name: "Stress Test (Worst-Case Data)",
90
+ render: () => ({
128
91
  components: { LinkText },
129
- setup() {
130
- return { args };
131
- },
132
92
  template: `
133
- <LinkText v-bind="args">
134
- <template #right>
135
- <Icon name="lucide:external-link" />
136
- </template>
137
- </LinkText>
93
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.6rem;">
94
+ <p style="margin: 0; font-size: 1.4rem;">Long German label, an unbroken label and a long URL as the label, each with icons, in an 18rem column.</p>
95
+ <div style="display: grid; gap: 1.6rem; justify-items: start; inline-size: 18rem; outline: 0.1rem dashed currentColor;">
96
+ <LinkText to="/" link-text="Alle Lieferbedingungen und Rückgaberichtlinien ansehen">
97
+ <template #right><Icon name="lucide:arrow-right" /></template>
98
+ </LinkText>
99
+ <LinkText to="/" link-text="Lieferbedingungenundrückgaberichtlinienansehen">
100
+ <template #left><Icon name="lucide:arrow-left" /></template>
101
+ <template #right><Icon name="lucide:arrow-right" /></template>
102
+ </LinkText>
103
+ <LinkText to="https://example.invalid/a/very/long/unbroken/url" link-text="https://example.invalid/a/very/long/unbroken/url" external target="_blank">
104
+ <template #right><Icon name="lucide:external-link" /></template>
105
+ </LinkText>
106
+ </div>
107
+
108
+ <p style="margin: 0; font-size: 1.4rem;">Emoji first, HTML-like text (shown as text), right-to-left with a right icon, and a single character.</p>
109
+ <div style="display: flex; flex-wrap: wrap; gap: 2.4rem; align-items: center;">
110
+ <LinkText to="/" link-text="😀 Emoji first" />
111
+ <LinkText to="/" link-text="<b>not bold</b>" />
112
+ <LinkText to="/" link-text="اقرأ المزيد" dir="rtl">
113
+ <template #right><Icon name="lucide:arrow-left" /></template>
114
+ </LinkText>
115
+ <LinkText to="/" link-text="A" />
116
+ </div>
117
+
118
+ <p style="margin: 0; font-size: 1.4rem;">Icon-only link: empty <code>link-text</code>, named with <code>aria-label</code>. Tab to it to check the focus ring.</p>
119
+ <LinkText to="/" link-text="" aria-label="Back to home">
120
+ <template #left><Icon name="lucide:house" /></template>
121
+ </LinkText>
122
+
123
+ <p style="margin: 0; font-size: 1.4rem;">Inside a sentence: the link sits on the text line.</p>
124
+ <p style="margin: 0;">Our full price list is on the <LinkText to="/" link-text="services page" /> and updated monthly.</p>
125
+ </div>
138
126
  `,
139
127
  }),
128
+ parameters: {
129
+ controls: { disable: true },
130
+ docs: {
131
+ description: {
132
+ story:
133
+ "Worst-case labels. Check: long and unbroken labels and URLs wrap inside the dashed 18rem column with their icons centred on the wrapped text; emoji and HTML-like text render as text; right-to-left puts the right slot on the inline end; an icon-only link keeps a visible focus ring; an inline link sits on the line of text.",
134
+ },
135
+ },
136
+ },
140
137
  };
@@ -1,177 +1,168 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import LinkText from "../LinkText.vue";
4
4
 
5
- describe("LinkText", () => {
6
- // ─── Mount ───────────────────────────────────────────────────────────────
5
+ type LinkTextProps = InstanceType<typeof LinkText>["$props"];
6
+
7
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
8
+
9
+ const mountLink = async (props: Partial<LinkTextProps> = {}, slots: Record<string, string> = {}) => {
10
+ const wrapper = await mountSuspended(LinkText, {
11
+ props: { to: "/about", linkText: "About Us", ...props } as LinkTextProps,
12
+ slots,
13
+ });
14
+ mounted.push(wrapper);
15
+ return wrapper;
16
+ };
7
17
 
18
+ afterEach(() => {
19
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe("LinkText", () => {
8
24
  it("mounts without error", async () => {
9
- const wrapper = await mountSuspended(LinkText, {
10
- props: { to: "/about", linkText: "About Us" },
11
- });
25
+ const wrapper = await mountLink();
12
26
  expect(wrapper.vm).toBeTruthy();
13
27
  });
14
28
 
15
29
  // ─── Snapshots ───────────────────────────────────────────────────────────
16
30
 
17
31
  it("renders correct HTML structure with default props", async () => {
18
- const wrapper = await mountSuspended(LinkText, {
19
- props: { to: "/about", linkText: "About Us" },
20
- });
32
+ const wrapper = await mountLink();
21
33
  expect(wrapper.html()).toMatchSnapshot();
22
34
  });
23
35
 
24
36
  it("renders correct HTML structure with left slot", async () => {
25
- const wrapper = await mountSuspended(LinkText, {
26
- props: { to: "/about", linkText: "About Us" },
27
- slots: { left: "<svg data-testid='icon-left' />" },
28
- });
37
+ const wrapper = await mountLink({}, { left: "<svg data-testid='icon-left' />" });
29
38
  expect(wrapper.html()).toMatchSnapshot();
30
39
  });
31
40
 
32
41
  it("renders correct HTML structure with right slot", async () => {
33
- const wrapper = await mountSuspended(LinkText, {
34
- props: { to: "/about", linkText: "About Us" },
35
- slots: { right: "<svg data-testid='icon-right' />" },
36
- });
42
+ const wrapper = await mountLink({}, { right: "<svg data-testid='icon-right' />" });
37
43
  expect(wrapper.html()).toMatchSnapshot();
38
44
  });
39
45
 
40
46
  it("renders correct HTML structure with both slots", async () => {
41
- const wrapper = await mountSuspended(LinkText, {
42
- props: { to: "/about", linkText: "About Us" },
43
- slots: {
44
- left: "<svg data-testid='icon-left' />",
45
- right: "<svg data-testid='icon-right' />",
46
- },
47
- });
47
+ const wrapper = await mountLink(
48
+ {},
49
+ { left: "<svg data-testid='icon-left' />", right: "<svg data-testid='icon-right' />" }
50
+ );
48
51
  expect(wrapper.html()).toMatchSnapshot();
49
52
  });
50
53
 
51
54
  it("renders correct HTML structure with all props set", async () => {
52
- const wrapper = await mountSuspended(LinkText, {
53
- props: {
54
- to: "https://example.com",
55
- linkText: "Visit Us",
56
- external: true,
57
- target: "_blank",
58
- styleClassPassthrough: ["custom-class"],
59
- },
55
+ const wrapper = await mountLink({
56
+ to: "https://example.com",
57
+ linkText: "Visit Us",
58
+ external: true,
59
+ target: "_blank",
60
+ styleClassPassthrough: ["custom-class"],
60
61
  });
61
62
  expect(wrapper.html()).toMatchSnapshot();
62
63
  });
63
64
 
64
- // ─── Base class ──────────────────────────────────────────────────────────
65
+ // ─── Root and label ──────────────────────────────────────────────────────
65
66
 
66
67
  it("always has the link-text class", async () => {
67
- const wrapper = await mountSuspended(LinkText, {
68
- props: { to: "/", linkText: "Home" },
69
- });
68
+ const wrapper = await mountLink({ to: "/", linkText: "Home" });
70
69
  expect(wrapper.classes()).toContain("link-text");
71
70
  });
72
71
 
73
- // ─── linkText ────────────────────────────────────────────────────────────
74
-
75
72
  it("renders the linkText in a label span", async () => {
76
- const wrapper = await mountSuspended(LinkText, {
77
- props: { to: "/", linkText: "Read More" },
78
- });
73
+ const wrapper = await mountLink({ linkText: "Read More" });
79
74
  expect(wrapper.find(".link-text__label").text()).toBe("Read More");
80
75
  });
81
76
 
82
77
  it("renders linkText with special characters", async () => {
83
- const wrapper = await mountSuspended(LinkText, {
84
- props: { to: "/", linkText: "Édition & Co." },
85
- });
78
+ const wrapper = await mountLink({ linkText: "Édition & Co." });
86
79
  expect(wrapper.find(".link-text__label").text()).toBe("Édition & Co.");
87
80
  });
88
81
 
82
+ it("renders HTML-like linkText as text", async () => {
83
+ const wrapper = await mountLink({ linkText: "<b>not bold</b>" });
84
+ expect(wrapper.find("b").exists()).toBe(false);
85
+ expect(wrapper.find(".link-text__label").text()).toBe("<b>not bold</b>");
86
+ });
87
+
88
+ it("updates the label when linkText changes after mount", async () => {
89
+ const wrapper = await mountLink({ linkText: "Before" });
90
+ await wrapper.setProps({ linkText: "After" });
91
+ expect(wrapper.find(".link-text__label").text()).toBe("After");
92
+ });
93
+
89
94
  // ─── Slots ───────────────────────────────────────────────────────────────
90
95
 
91
96
  it("renders no icon spans when no slots provided", async () => {
92
- const wrapper = await mountSuspended(LinkText, {
93
- props: { to: "/", linkText: "No Icons" },
94
- });
97
+ const wrapper = await mountLink();
95
98
  expect(wrapper.find(".link-text__icon--left").exists()).toBe(false);
96
99
  expect(wrapper.find(".link-text__icon--right").exists()).toBe(false);
97
100
  });
98
101
 
99
102
  it("renders left icon span when left slot is provided", async () => {
100
- const wrapper = await mountSuspended(LinkText, {
101
- props: { to: "/", linkText: "With Left Icon" },
102
- slots: { left: "<svg />" },
103
- });
103
+ const wrapper = await mountLink({}, { left: "<svg />" });
104
104
  expect(wrapper.find(".link-text__icon--left").exists()).toBe(true);
105
105
  expect(wrapper.find(".link-text__icon--right").exists()).toBe(false);
106
106
  });
107
107
 
108
108
  it("renders right icon span when right slot is provided", async () => {
109
- const wrapper = await mountSuspended(LinkText, {
110
- props: { to: "/", linkText: "With Right Icon" },
111
- slots: { right: "<svg />" },
112
- });
109
+ const wrapper = await mountLink({}, { right: "<svg />" });
113
110
  expect(wrapper.find(".link-text__icon--left").exists()).toBe(false);
114
111
  expect(wrapper.find(".link-text__icon--right").exists()).toBe(true);
115
112
  });
116
113
 
117
- it("renders both icon spans when both slots are provided", async () => {
118
- const wrapper = await mountSuspended(LinkText, {
119
- props: { to: "/", linkText: "Both Icons" },
120
- slots: { left: "<svg />", right: "<svg />" },
121
- });
122
- expect(wrapper.find(".link-text__icon--left").exists()).toBe(true);
123
- expect(wrapper.find(".link-text__icon--right").exists()).toBe(true);
114
+ it("renders left icon before label and right icon after label", async () => {
115
+ const wrapper = await mountLink({}, { left: "<span class='l' />", right: "<span class='r' />" });
116
+ const children = Array.from((wrapper.element as HTMLElement).children);
117
+ expect(children.map((child) => child.classList[1] ?? child.classList[0])).toEqual([
118
+ "link-text__icon--left",
119
+ "link-text__label",
120
+ "link-text__icon--right",
121
+ ]);
124
122
  });
125
123
 
126
- // ─── Props ───────────────────────────────────────────────────────────────
124
+ // ─── Link attributes ─────────────────────────────────────────────────────
125
+
126
+ it("renders the to prop as href", async () => {
127
+ const wrapper = await mountLink({ to: "/services" });
128
+ expect(wrapper.attributes("href")).toBe("/services");
129
+ });
127
130
 
128
131
  it("passes target prop to NuxtLink", async () => {
132
+ const wrapper = await mountLink({ to: "https://example.com", target: "_blank" });
133
+ expect(wrapper.attributes("target")).toBe("_blank");
134
+ });
135
+
136
+ it("adds rel noopener to an external link opening in a new tab", async () => {
137
+ const wrapper = await mountLink({ to: "https://example.com", external: true, target: "_blank" });
138
+ expect(wrapper.attributes("rel")).toContain("noopener");
139
+ });
140
+
141
+ it("forwards an aria-label to the link for an icon-only link", async () => {
129
142
  const wrapper = await mountSuspended(LinkText, {
130
- props: { to: "https://example.com", linkText: "External", target: "_blank" },
143
+ props: { to: "/", linkText: "" },
144
+ attrs: { "aria-label": "Home" },
145
+ slots: { left: "<svg />" },
131
146
  });
132
- expect(wrapper.attributes("target")).toBe("_blank");
147
+ mounted.push(wrapper);
148
+ expect(wrapper.attributes("aria-label")).toBe("Home");
133
149
  });
134
150
 
135
151
  // ─── styleClassPassthrough ───────────────────────────────────────────────
136
152
 
137
153
  it("applies a styleClassPassthrough array", async () => {
138
- const wrapper = await mountSuspended(LinkText, {
139
- props: {
140
- to: "/",
141
- linkText: "Styled",
142
- styleClassPassthrough: ["class-a", "class-b"],
143
- },
144
- });
154
+ const wrapper = await mountLink({ styleClassPassthrough: ["class-a", "class-b"] });
145
155
  expect(wrapper.classes()).toContain("class-a");
146
156
  expect(wrapper.classes()).toContain("class-b");
147
157
  });
148
158
 
149
159
  it("applies a single styleClassPassthrough string", async () => {
150
- const wrapper = await mountSuspended(LinkText, {
151
- props: { to: "/", linkText: "Styled", styleClassPassthrough: "solo-class" },
152
- });
160
+ const wrapper = await mountLink({ styleClassPassthrough: "solo-class" });
153
161
  expect(wrapper.classes()).toContain("solo-class");
154
162
  });
155
163
 
156
- // ─── DOM order ───────────────────────────────────────────────────────────
157
-
158
- it("renders left icon before label and right icon after label", async () => {
159
- const wrapper = await mountSuspended(LinkText, {
160
- props: { to: "/", linkText: "Ordered" },
161
- slots: { left: "<span class='l' />", right: "<span class='r' />" },
162
- });
163
- const children = wrapper.element.children;
164
- expect(children[0].classList.contains("link-text__icon--left")).toBe(true);
165
- expect(children[1].classList.contains("link-text__label")).toBe(true);
166
- expect(children[2].classList.contains("link-text__icon--right")).toBe(true);
167
- });
168
- });
169
-
170
- describe("LinkText styleClassPassthrough reactivity", () => {
171
164
  it("updates classes when styleClassPassthrough changes after mount", async () => {
172
- const wrapper = await mountSuspended(LinkText, {
173
- props: { to: "/about", linkText: "About", styleClassPassthrough: ["original"] },
174
- });
165
+ const wrapper = await mountLink({ styleClassPassthrough: ["original"] });
175
166
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
176
167
  expect(wrapper.classes()).not.toContain("original");
177
168
  expect(wrapper.classes()).toContain("updated");
@@ -3,14 +3,14 @@
3
3
  exports[`PriceList > Snapshots > custom headings 1`] = `
4
4
  "<div class="price-list">
5
5
  <div class="price-list__column">
6
- <h2 class="hero-text price-list__heading subheading axis-horizontal">
7
- <!--v-if--><span class="text-block-0">Cuts</span>
6
+ <h2 class="hero-text price-list__heading" data-font-size="subheading" data-axis="horizontal">
7
+ <!--v-if--><span class="hero-text-segment">Cuts</span>
8
8
  </h2>
9
9
  <dl class="price-list__list"></dl>
10
10
  </div>
11
11
  <div class="price-list__column">
12
- <h2 class="hero-text price-list__heading subheading axis-horizontal">
13
- <!--v-if--><span class="text-block-0">Colour</span>
12
+ <h2 class="hero-text price-list__heading" data-font-size="subheading" data-axis="horizontal">
13
+ <!--v-if--><span class="hero-text-segment">Colour</span>
14
14
  </h2>
15
15
  <dl class="price-list__list"></dl>
16
16
  </div>
@@ -20,8 +20,8 @@ exports[`PriceList > Snapshots > custom headings 1`] = `
20
20
  exports[`PriceList > Snapshots > default 1`] = `
21
21
  "<div class="price-list">
22
22
  <div class="price-list__column">
23
- <h2 class="hero-text price-list__heading subheading axis-horizontal">
24
- <!--v-if--><span class="text-block-0">Cutting &amp; Treatment</span>
23
+ <h2 class="hero-text price-list__heading" data-font-size="subheading" data-axis="horizontal">
24
+ <!--v-if--><span class="hero-text-segment">Cutting &amp; Treatment</span>
25
25
  </h2>
26
26
  <dl class="price-list__list">
27
27
  <div class="price-list__row">
@@ -39,8 +39,8 @@ exports[`PriceList > Snapshots > default 1`] = `
39
39
  </dl>
40
40
  </div>
41
41
  <div class="price-list__column">
42
- <h2 class="hero-text price-list__heading subheading axis-horizontal">
43
- <!--v-if--><span class="text-block-0">Hair Colouring</span>
42
+ <h2 class="hero-text price-list__heading" data-font-size="subheading" data-axis="horizontal">
43
+ <!--v-if--><span class="hero-text-segment">Hair Colouring</span>
44
44
  </h2>
45
45
  <dl class="price-list__list">
46
46
  <div class="price-list__row">
@@ -0,0 +1,109 @@
1
+ # SlidingToggle — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ ### Track
6
+
7
+ | Token | Default | Controls |
8
+ |---|---|---|
9
+ | `--sliding-toggle-surface` | `var(--theme-surface-subtle)` | Track background |
10
+ | `--sliding-toggle-padding` | `0.4rem` | Space between the track edge and the options (the indicators sit inside it too) |
11
+ | `--sliding-toggle-gap` | `0.4rem` | Space between options |
12
+ | `--sliding-toggle-border-radius` | `0.8rem` | Track corner radius |
13
+ | `--sliding-toggle-corner-shape` | `round` | `corner-shape` of the track, both indicators and the options (`squircle`, `superellipse(n)`, `bevel`, `scoop`, `notch`). Chromium 139+ only; other browsers ignore it and keep `border-radius` |
14
+
15
+ ### Indicators
16
+
17
+ | Token | Default | Controls |
18
+ |---|---|---|
19
+ | `--sliding-toggle-indicator-surface` | `var(--theme-on-surface)` | Background of the selected option's sliding indicator |
20
+ | `--sliding-toggle-indicator-shadow` | `0 0.1rem 0.3rem rgb(0 0 0 / 0.1)` | Shadow on the selected indicator |
21
+ | `--sliding-toggle-hover-indicator-surface` | `color-mix(in oklch, var(--theme-surface-subtle), var(--theme-text) 6%)` | Background of the hover indicator, one step darker than the track |
22
+ | `--sliding-toggle-indicator-stretch` | `1` | Multiplies both indicators' edge delays: `1` stretches then catches up, `0` slides both edges together, values between shorten the stretch |
23
+ | `--sliding-toggle-marker-height` | `0.2rem` | Height of the accent line along the selected indicator's bottom edge (`0` hides it) |
24
+ | `--sliding-toggle-marker-colour` | `var(--theme-accent)` | Colour of that line |
25
+
26
+ ### Options
27
+
28
+ | Token | Default | Controls |
29
+ |---|---|---|
30
+ | `--sliding-toggle-option-padding` | `0.8rem 1.6rem` | Option padding |
31
+ | `--sliding-toggle-option-border-radius` | `0.6rem` | Corner radius of the options and both indicators |
32
+ | `--sliding-toggle-option-flex` | `0 1 auto` | Option `flex`. `1 1 0` gives equal widths (stretch the root too) |
33
+ | `--sliding-toggle-option-font-size` | `inherit` | Label font size |
34
+ | `--sliding-toggle-option-font-weight` | `500` | Label font weight |
35
+ | `--sliding-toggle-option-white-space` | `normal` | Label `white-space`; `nowrap` keeps each label on one line |
36
+ | `--sliding-toggle-option-colour` | `color-mix(in oklch, var(--theme-text) 70%, transparent)` | Label colour |
37
+ | `--sliding-toggle-option-colour-active` | `var(--theme-text)` | Selected option's label colour |
38
+ | `--sliding-toggle-transition-duration` | `200ms` | Label colour fade. The indicator slide is the `transitionDuration` prop |
39
+ | `--sliding-toggle-focus-ring-width` | `0.2rem` | Focus ring width |
40
+ | `--sliding-toggle-focus-ring-colour` | `var(--theme-border-focus)` | Focus ring colour |
41
+
42
+ Private (not public API): `--_sliding-toggle-active-*` and `--_sliding-toggle-hover-*` (`-start`,
43
+ `-end`, `-duration`, `-start-delay`, `-end-delay`), written on the root by `useSlidingIndicator`
44
+ from the measured option positions.
45
+
46
+ ---
47
+
48
+ ## State hooks
49
+
50
+ | Hook | Where | Meaning |
51
+ |---|---|---|
52
+ | `[data-ready]` | root | Mounted and measured. Before this (SSR, first paint) the indicators are hidden and the selected option paints its own background and marker |
53
+ | `[data-has-selection]` | root | `v-model` matches an option |
54
+ | `[data-hovering]` | root | The pointer is over an enabled option (with `trackHover`) |
55
+ | `[aria-disabled="true"]` | root | The `disabled` prop |
56
+ | `[aria-checked="true"]` | `.sliding-toggle-option` | The selected option |
57
+ | `:disabled` | `.sliding-toggle-option` | A disabled option, or the whole group |
58
+
59
+ Inner classes: `.sliding-toggle-option` (each `<button role="radio">`), `.sliding-toggle-indicator`
60
+ with `.sliding-toggle-indicator-active` and `.sliding-toggle-indicator-hover`. The marker is the
61
+ active indicator's `::after`.
62
+
63
+ ---
64
+
65
+ ## Motion
66
+
67
+ The indicator edges slide with `useSlidingIndicator`: on a move the edge in the direction of travel
68
+ goes first and the other follows one `transitionDuration` later. `--sliding-toggle-indicator-stretch: 0`
69
+ turns the stretch into a plain slide for both indicators (same as TabbedContent's
70
+ `--tabs-indicator-stretch`). Under `prefers-reduced-motion` both edges snap and the label colour
71
+ doesn't fade.
72
+
73
+ ---
74
+
75
+ ## Local overrides
76
+
77
+ Set the tokens above on an element you own (a page or section class, or a class added with
78
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
79
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
80
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
81
+ `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
82
+
83
+ ### Equal-width options
84
+
85
+ ```css
86
+ .billing-switch {
87
+ --sliding-toggle-option-flex: 1 1 0;
88
+ --sliding-toggle-corner-shape: squircle;
89
+ }
90
+
91
+ .billing-switch .sliding-toggle {
92
+ inline-size: 100%;
93
+ }
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Class passthrough
99
+
100
+ `style-class-passthrough` adds classes to the root `.sliding-toggle` (the radiogroup). Reactive
101
+ after mount.
102
+
103
+ ---
104
+
105
+ ## Notes
106
+
107
+ - The root is `inline-size: fit-content` capped at `max-inline-size: 100%`; labels shrink and wrap
108
+ (`overflow-wrap: anywhere`) rather than overflow, and the indicators re-measure as they do.
109
+ - No line-clamp token: labels name the choice and must stay readable.