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.
- package/.claude/rules/consumer/story-register-components.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +5 -0
- package/.claude/skills/components/display-qr-code.md +22 -4
- package/.claude/skills/components/eyebrow-text.md +9 -5
- package/.claude/skills/components/header-block.md +13 -5
- package/.claude/skills/components/hero-text.md +13 -9
- package/.claude/skills/components/link-text.md +8 -5
- package/.claude/skills/components/pop-over.md +17 -3
- package/.claude/skills/components/sliding-toggle.md +108 -0
- package/.claude/skills/components/tabbed-content.md +8 -3
- package/.claude/skills/composable-sliding-indicator.md +60 -0
- package/.claude/skills/index.md +2 -0
- package/.claude/skills/storybook-add-story.md +22 -2
- package/.claude/skills/testing-add-playwright.md +12 -7
- package/.claude/skills/testing-add-unit-test.md +25 -0
- package/.claude/skills/theming-component-token-pattern.md +18 -0
- package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
- package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
- package/.vscode/srcdev-component-link-text.code-snippets +3 -3
- package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
- package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
- package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
- package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
- package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
- package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
- package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
- package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
- package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
- package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
- package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
- package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
- package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
- package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
- package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
- package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
- package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
- package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
- package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
- package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
- package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
- package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
- package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
- package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
- package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
- package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
- package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
- package/app/composables/useSlidingIndicator.ts +56 -0
- package/app/composables/useTabs.ts +4 -31
- package/app/types/components/index.ts +1 -0
- package/app/types/components/sliding-toggle.d.ts +5 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
96
|
-
args
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
<
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
// ───
|
|
65
|
+
// ─── Root and label ──────────────────────────────────────────────────────
|
|
65
66
|
|
|
66
67
|
it("always has the link-text class", async () => {
|
|
67
|
-
const wrapper = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
118
|
-
const wrapper = await
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
// ───
|
|
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: "
|
|
143
|
+
props: { to: "/", linkText: "" },
|
|
144
|
+
attrs: { "aria-label": "Home" },
|
|
145
|
+
slots: { left: "<svg />" },
|
|
131
146
|
});
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7
|
-
<!--v-if--><span class="text-
|
|
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
|
|
13
|
-
<!--v-if--><span class="text-
|
|
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
|
|
24
|
-
<!--v-if--><span class="text-
|
|
23
|
+
<h2 class="hero-text price-list__heading" data-font-size="subheading" data-axis="horizontal">
|
|
24
|
+
<!--v-if--><span class="hero-text-segment">Cutting & 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
|
|
43
|
-
<!--v-if--><span class="text-
|
|
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.
|