srcdev-nuxt-components 9.6.12 → 9.6.14
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/ids-from-useid.md +2 -1
- package/.claude/rules/consumer/story-register-components.md +1 -1
- package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +24 -1
- package/.claude/skills/component-naming.md +1 -0
- package/.claude/skills/components/banner-video.md +24 -30
- package/.claude/skills/components/content-docs.md +18 -5
- package/.claude/skills/components/display-dialog.md +16 -6
- package/.claude/skills/components/display-qr-code.md +22 -4
- package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
- package/.claude/skills/components/display-tooltip.md +17 -6
- 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/skip-links.md +16 -0
- package/.claude/skills/composable-tooltips-guide.md +4 -4
- package/.claude/skills/index.md +6 -6
- package/.claude/skills/storybook-add-story.md +5 -2
- package/.claude/skills/testing-add-playwright.md +12 -7
- package/.claude/skills/testing-add-unit-test.md +8 -0
- package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
- package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
- package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
- package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
- package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
- package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
- 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-skip-links.code-snippets +1 -1
- package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
- package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
- package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
- package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
- package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
- package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
- package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
- package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
- package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
- package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
- package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
- package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
- package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
- package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
- 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/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/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
- package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
- package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
- package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
- package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
- package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -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/useTooltips.spec.ts +3 -3
- package/app/types/components/index.ts +1 -1
- package/package.json +1 -1
- package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
- package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
- /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
<div v-if="$slots.homeLink" class="skip-links__home">
|
|
4
4
|
<slot name="homeLink"></slot>
|
|
5
5
|
</div>
|
|
6
|
-
<nav class="skip-links__nav" :aria-label="ariaLabel">
|
|
7
|
-
<a
|
|
6
|
+
<nav v-if="validLinks.length" class="skip-links__nav" :aria-label="ariaLabel">
|
|
7
|
+
<a
|
|
8
|
+
v-for="(link, index) in validLinks"
|
|
9
|
+
:key="`${index}-${link.href}`"
|
|
10
|
+
:href="link.href"
|
|
11
|
+
class="skip-links__link"
|
|
12
|
+
>{{ link.label }}</a>
|
|
8
13
|
</nav>
|
|
9
14
|
</div>
|
|
10
15
|
</template>
|
|
@@ -28,6 +33,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
28
33
|
styleClassPassthrough: () => [],
|
|
29
34
|
})
|
|
30
35
|
|
|
36
|
+
const validLinks = computed(() => props.links.filter((link) => link.href?.trim() && link.label?.trim()))
|
|
37
|
+
|
|
31
38
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
32
39
|
|
|
33
40
|
watch(
|
|
@@ -46,22 +53,21 @@ watch(
|
|
|
46
53
|
.skip-links__nav {
|
|
47
54
|
position: absolute;
|
|
48
55
|
top: 100%;
|
|
49
|
-
|
|
50
|
-
z-index: var(--skip-links-z-index,
|
|
56
|
+
inset-inline-start: 0;
|
|
57
|
+
z-index: var(--skip-links-z-index, 999999);
|
|
51
58
|
|
|
52
59
|
display: flex;
|
|
53
60
|
flex-direction: column;
|
|
54
61
|
gap: var(--skip-links-gap, 0.2rem);
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
inline-size: max-content;
|
|
63
|
+
max-inline-size: calc(100vw - 3.2rem);
|
|
64
|
+
background-color: var(--skip-links-background-colour, var(--theme-surface-inverted));
|
|
65
|
+
border: var(--skip-links-border-width, 1px) solid var(--skip-links-border-colour, var(--theme-on-surface));
|
|
57
66
|
padding: var(--skip-links-padding, 0.2rem);
|
|
58
67
|
margin: 0;
|
|
59
68
|
|
|
60
|
-
/* Hidden
|
|
61
|
-
|
|
62
|
-
(the homeLink slot), and opacity:0 alone doesn't stop it from catching hover/clicks meant
|
|
63
|
-
for whatever sits underneath it. Keyboard tab focus doesn't need pointer-events, so this
|
|
64
|
-
doesn't affect focusability. */
|
|
69
|
+
/* Hidden by transform, not display, so the links stay focusable; pointer-events stops the
|
|
70
|
+
invisible panel catching clicks meant for the homeLink slot it overlaps. */
|
|
65
71
|
opacity: 0;
|
|
66
72
|
pointer-events: none;
|
|
67
73
|
transform: translateY(-100%);
|
|
@@ -78,13 +84,13 @@ watch(
|
|
|
78
84
|
|
|
79
85
|
.skip-links__link {
|
|
80
86
|
display: block;
|
|
81
|
-
color: var(--skip-links-text-colour,
|
|
87
|
+
color: var(--skip-links-text-colour, var(--theme-on-surface));
|
|
82
88
|
text-decoration: none;
|
|
83
89
|
padding: var(--skip-links-link-padding-block, 0.8rem) var(--skip-links-link-padding-inline, 1.2rem);
|
|
84
|
-
|
|
90
|
+
overflow-wrap: anywhere;
|
|
85
91
|
|
|
86
92
|
&:focus-visible {
|
|
87
|
-
outline: var(--skip-links-focus-outline-width, 2px) solid var(--skip-links-focus-outline-colour,
|
|
93
|
+
outline: var(--skip-links-focus-outline-width, 2px) solid var(--skip-links-focus-outline-colour, var(--theme-on-surface));
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
96
|
}
|
|
@@ -8,11 +8,11 @@ const meta: Meta<typeof SkipLinks> = {
|
|
|
8
8
|
argTypes: {
|
|
9
9
|
links: {
|
|
10
10
|
control: "object",
|
|
11
|
-
description: "Ordered list of { href, label } skip targets",
|
|
11
|
+
description: "Ordered list of { href, label } skip targets. Entries with an empty href or label are skipped.",
|
|
12
12
|
},
|
|
13
13
|
ariaLabel: {
|
|
14
14
|
control: "text",
|
|
15
|
-
description: "aria-label on the nav landmark
|
|
15
|
+
description: "aria-label on the nav landmark (override for localisation)",
|
|
16
16
|
},
|
|
17
17
|
styleClassPassthrough: {
|
|
18
18
|
control: "object",
|
|
@@ -27,7 +27,7 @@ const meta: Meta<typeof SkipLinks> = {
|
|
|
27
27
|
docs: {
|
|
28
28
|
description: {
|
|
29
29
|
component:
|
|
30
|
-
"
|
|
30
|
+
"Keyboard skip links: a `<nav>` of in-page links that stays hidden until one of them takes focus, letting keyboard and screen-reader users jump past repeated navigation. Place `<SkipLinks>` first in the page layout. Links come from the `links` prop (`{ href, label }[]`, default main content and footer); the optional `homeLink` slot renders a visible item (usually the logo link) that the panel drops down beneath. `ariaLabel` names the landmark. SiteHeader renders this as its branding row: see the [Site Header](?path=/story/organisms-site-header--default) story.",
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
33
|
},
|
|
@@ -36,7 +36,24 @@ const meta: Meta<typeof SkipLinks> = {
|
|
|
36
36
|
export default meta;
|
|
37
37
|
type Story = StoryObj<typeof SkipLinks>;
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
|
|
40
|
+
const siteHeaderNote = `
|
|
41
|
+
<p style="${storyNoteStyle}">
|
|
42
|
+
SiteHeader puts its branding in the <code>homeLink</code> slot and these links beneath it: open the
|
|
43
|
+
<a href="/?path=/story/organisms-site-header--default" target="_top">Site Header</a> story to see it in a real header.
|
|
44
|
+
</p>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const forceOpenStyle = `
|
|
48
|
+
<style>
|
|
49
|
+
.skip-links-force-open .skip-links__nav {
|
|
50
|
+
opacity: 1 !important;
|
|
51
|
+
pointer-events: auto !important;
|
|
52
|
+
transform: translateY(0) !important;
|
|
53
|
+
position: static !important;
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
56
|
+
`;
|
|
40
57
|
|
|
41
58
|
export const Default: Story = {
|
|
42
59
|
render: (args) => ({
|
|
@@ -45,10 +62,10 @@ export const Default: Story = {
|
|
|
45
62
|
return { args };
|
|
46
63
|
},
|
|
47
64
|
template: `
|
|
48
|
-
<div>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
press <kbd>Tab</kbd> to reveal them.
|
|
65
|
+
<div style="padding: 2rem;">
|
|
66
|
+
${siteHeaderNote}
|
|
67
|
+
<p style="${storyNoteStyle}">
|
|
68
|
+
The skip links are hidden until focused. Click into this preview, then press <kbd>Tab</kbd> to reveal them.
|
|
52
69
|
</p>
|
|
53
70
|
<SkipLinks v-bind="args" />
|
|
54
71
|
</div>
|
|
@@ -71,8 +88,9 @@ export const CustomLinks: Story = {
|
|
|
71
88
|
return { args };
|
|
72
89
|
},
|
|
73
90
|
template: `
|
|
74
|
-
<div>
|
|
75
|
-
|
|
91
|
+
<div style="padding: 2rem;">
|
|
92
|
+
${siteHeaderNote}
|
|
93
|
+
<p style="${storyNoteStyle}">
|
|
76
94
|
Press <kbd>Tab</kbd> after clicking into this preview to reveal three custom skip links.
|
|
77
95
|
</p>
|
|
78
96
|
<SkipLinks v-bind="args" />
|
|
@@ -86,7 +104,8 @@ export const AlwaysVisible: Story = {
|
|
|
86
104
|
parameters: {
|
|
87
105
|
docs: {
|
|
88
106
|
description: {
|
|
89
|
-
story:
|
|
107
|
+
story:
|
|
108
|
+
"Forces the panel open with story-only CSS so it can be inspected without tabbing. For visual review only: in real use it only shows while a link has focus.",
|
|
90
109
|
},
|
|
91
110
|
},
|
|
92
111
|
},
|
|
@@ -96,18 +115,68 @@ export const AlwaysVisible: Story = {
|
|
|
96
115
|
return { args };
|
|
97
116
|
},
|
|
98
117
|
template: `
|
|
99
|
-
<div>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
transform: translateY(0) !important;
|
|
104
|
-
position: static !important;
|
|
105
|
-
}
|
|
106
|
-
</style>
|
|
107
|
-
<div class="always-visible-demo">
|
|
118
|
+
<div style="padding: 2rem;">
|
|
119
|
+
${forceOpenStyle}
|
|
120
|
+
${siteHeaderNote}
|
|
121
|
+
<div class="skip-links-force-open">
|
|
108
122
|
<SkipLinks v-bind="args" />
|
|
109
123
|
</div>
|
|
110
124
|
</div>
|
|
111
125
|
`,
|
|
112
126
|
}),
|
|
113
127
|
};
|
|
128
|
+
|
|
129
|
+
const stressLinks: SkipLink[] = [
|
|
130
|
+
{ href: "#main-content", label: "Zumhauptinhaltspringenundnavigationüberspringen" },
|
|
131
|
+
{ href: "#main-content", label: "😀 Same target, second label" },
|
|
132
|
+
{ href: "#search", label: "<b>not bold</b> renders as text" },
|
|
133
|
+
{ href: "#footer-content", label: "انتقل إلى التذييل" },
|
|
134
|
+
{ href: "#x", label: "X" },
|
|
135
|
+
{ href: "", label: "Dropped: empty href" },
|
|
136
|
+
{ href: "#dropped", label: " " },
|
|
137
|
+
...Array.from({ length: 6 }, (_, index) => ({ href: `#section-${index + 1}`, label: `Skip to section ${index + 1}` })),
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
export const StressTest: Story = {
|
|
141
|
+
name: "Stress Test (Worst-Case Data)",
|
|
142
|
+
args: {
|
|
143
|
+
links: stressLinks,
|
|
144
|
+
ariaLabel: "Navigationsübersprunglinksfürtastaturnutzerinnenundnutzer 😀",
|
|
145
|
+
},
|
|
146
|
+
render: (args) => ({
|
|
147
|
+
components: { SkipLinks },
|
|
148
|
+
setup() {
|
|
149
|
+
return { args };
|
|
150
|
+
},
|
|
151
|
+
template: `
|
|
152
|
+
<div style="display: grid; gap: 2.4rem; padding: 2rem; font-size: 1.4rem;">
|
|
153
|
+
${forceOpenStyle}
|
|
154
|
+
<p style="margin: 0;">Forced open in a narrow column: long unbroken, emoji, HTML-like and right-to-left labels wrap inside the panel, two links share a target, and the two entries with an empty href or label are dropped (11 links show, not 13).</p>
|
|
155
|
+
<div class="skip-links-force-open" style="inline-size: 18rem; outline: 0.1rem dashed currentColor;">
|
|
156
|
+
<SkipLinks v-bind="args" />
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<p style="margin: 0;">The real behaviour with the same data: press <kbd>Tab</kbd> from here. The panel drops down under the logo, stays inside the viewport and shows each link's focus ring.</p>
|
|
160
|
+
<SkipLinks v-bind="args">
|
|
161
|
+
<template #homeLink>
|
|
162
|
+
<a href="#" style="display: inline-block; padding: 0.8rem;">Logo</a>
|
|
163
|
+
</template>
|
|
164
|
+
</SkipLinks>
|
|
165
|
+
|
|
166
|
+
<p style="margin: 0;">No usable links (an empty array): nothing renders, not even an empty <code><nav></code> landmark.</p>
|
|
167
|
+
<div class="skip-links-force-open" style="outline: 0.1rem dashed currentColor; min-block-size: 2rem;">
|
|
168
|
+
<SkipLinks :links="[]" />
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
`,
|
|
172
|
+
}),
|
|
173
|
+
parameters: {
|
|
174
|
+
docs: {
|
|
175
|
+
story: { inline: false, height: "900px" },
|
|
176
|
+
description: {
|
|
177
|
+
story:
|
|
178
|
+
"Worst-case `links`. Check: labels wrap instead of running off-screen, the panel is never wider than the viewport, links that share an href both render, entries with an empty href or label are dropped, HTML-like text renders as text, and an empty list leaves no landmark behind. Tab through the second instance to check the reveal and focus rings. The long `ariaLabel` only names the landmark, so it never shows on screen.",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
};
|
|
@@ -1,21 +1,34 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
2
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
3
|
import SkipLinks from "../SkipLinks.vue";
|
|
4
4
|
import type { SkipLink } from "~/types/components";
|
|
5
5
|
|
|
6
|
+
const mounted: { unmount: () => void }[] = [];
|
|
7
|
+
|
|
8
|
+
const mountSkipLinks = async (options: Parameters<typeof mountSuspended<typeof SkipLinks>>[1] = {}) => {
|
|
9
|
+
const wrapper = await mountSuspended(SkipLinks, options);
|
|
10
|
+
mounted.push(wrapper);
|
|
11
|
+
return wrapper;
|
|
12
|
+
};
|
|
13
|
+
|
|
6
14
|
describe("SkipLinks", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
mounted.splice(0).forEach((wrapper) => wrapper.unmount());
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
7
20
|
it("mounts without error", async () => {
|
|
8
|
-
const wrapper = await
|
|
21
|
+
const wrapper = await mountSkipLinks();
|
|
9
22
|
expect(wrapper.vm).toBeTruthy();
|
|
10
23
|
});
|
|
11
24
|
|
|
12
25
|
it("renders correct HTML structure (default props)", async () => {
|
|
13
|
-
const wrapper = await
|
|
26
|
+
const wrapper = await mountSkipLinks();
|
|
14
27
|
expect(wrapper.html()).toMatchSnapshot();
|
|
15
28
|
});
|
|
16
29
|
|
|
17
30
|
it("renders correct HTML structure (all props set)", async () => {
|
|
18
|
-
const wrapper = await
|
|
31
|
+
const wrapper = await mountSkipLinks({
|
|
19
32
|
props: {
|
|
20
33
|
links: [{ href: "#content", label: "Skip to content" }],
|
|
21
34
|
ariaLabel: "Custom skip nav",
|
|
@@ -25,7 +38,7 @@ describe("SkipLinks", () => {
|
|
|
25
38
|
});
|
|
26
39
|
|
|
27
40
|
it("renders the default main-content and footer links", async () => {
|
|
28
|
-
const wrapper = await
|
|
41
|
+
const wrapper = await mountSkipLinks();
|
|
29
42
|
const links = wrapper.findAll(".skip-links__link");
|
|
30
43
|
expect(links).toHaveLength(2);
|
|
31
44
|
expect(links[0]!.attributes("href")).toBe("#main-content");
|
|
@@ -40,29 +53,76 @@ describe("SkipLinks", () => {
|
|
|
40
53
|
{ href: "#search", label: "Skip to search" },
|
|
41
54
|
{ href: "#content", label: "Skip to content" },
|
|
42
55
|
];
|
|
43
|
-
const wrapper = await
|
|
56
|
+
const wrapper = await mountSkipLinks({ props: { links: customLinks } });
|
|
44
57
|
const links = wrapper.findAll(".skip-links__link");
|
|
45
58
|
expect(links).toHaveLength(3);
|
|
46
59
|
expect(links.map((link) => link.text())).toEqual(["Skip to navigation", "Skip to search", "Skip to content"]);
|
|
47
60
|
});
|
|
48
61
|
|
|
62
|
+
it("drops links with an empty or whitespace-only href or label", async () => {
|
|
63
|
+
const wrapper = await mountSkipLinks({
|
|
64
|
+
props: {
|
|
65
|
+
links: [
|
|
66
|
+
{ href: "#a", label: "Keep" },
|
|
67
|
+
{ href: "", label: "No target" },
|
|
68
|
+
{ href: "#b", label: " " },
|
|
69
|
+
{ href: "#c", label: "Also keep" },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(wrapper.findAll(".skip-links__link").map((link) => link.text())).toEqual(["Keep", "Also keep"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps the right links when two share an href and the list is reordered", async () => {
|
|
77
|
+
const wrapper = await mountSkipLinks({
|
|
78
|
+
props: {
|
|
79
|
+
links: [
|
|
80
|
+
{ href: "#nav", label: "Skip to navigation" },
|
|
81
|
+
{ href: "#main", label: "Skip to content" },
|
|
82
|
+
{ href: "#main", label: "Skip to article" },
|
|
83
|
+
{ href: "#footer", label: "Skip to footer" },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
expect(wrapper.findAll(".skip-links__link")).toHaveLength(4);
|
|
88
|
+
await wrapper.setProps({
|
|
89
|
+
links: [
|
|
90
|
+
{ href: "#footer", label: "Skip to footer" },
|
|
91
|
+
{ href: "#main", label: "Skip to content" },
|
|
92
|
+
{ href: "#main", label: "Skip to article" },
|
|
93
|
+
{ href: "#nav", label: "Skip to navigation" },
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
expect(wrapper.findAll(".skip-links__link").map((link) => link.text())).toEqual([
|
|
97
|
+
"Skip to footer",
|
|
98
|
+
"Skip to content",
|
|
99
|
+
"Skip to article",
|
|
100
|
+
"Skip to navigation",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("omits the nav landmark entirely when there are no usable links", async () => {
|
|
105
|
+
const wrapper = await mountSkipLinks({ props: { links: [{ href: "", label: "" }] } });
|
|
106
|
+
expect(wrapper.find("nav").exists()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
49
109
|
it("defaults the nav aria-label to 'Skip navigation'", async () => {
|
|
50
|
-
const wrapper = await
|
|
110
|
+
const wrapper = await mountSkipLinks();
|
|
51
111
|
expect(wrapper.find(".skip-links__nav").attributes("aria-label")).toBe("Skip navigation");
|
|
52
112
|
});
|
|
53
113
|
|
|
54
114
|
it("uses a custom ariaLabel when provided", async () => {
|
|
55
|
-
const wrapper = await
|
|
115
|
+
const wrapper = await mountSkipLinks({ props: { ariaLabel: "Custom skip nav" } });
|
|
56
116
|
expect(wrapper.find(".skip-links__nav").attributes("aria-label")).toBe("Custom skip nav");
|
|
57
117
|
});
|
|
58
118
|
|
|
59
119
|
it("does not render the home-link slot wrapper when the slot is unused", async () => {
|
|
60
|
-
const wrapper = await
|
|
120
|
+
const wrapper = await mountSkipLinks();
|
|
61
121
|
expect(wrapper.find(".skip-links__home").exists()).toBe(false);
|
|
62
122
|
});
|
|
63
123
|
|
|
64
124
|
it("renders the homeLink slot content when provided", async () => {
|
|
65
|
-
const wrapper = await
|
|
125
|
+
const wrapper = await mountSkipLinks({
|
|
66
126
|
slots: { homeLink: "<a href='/' class='logo-link'>Home</a>" },
|
|
67
127
|
});
|
|
68
128
|
expect(wrapper.find(".skip-links__home").exists()).toBe(true);
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
| `--pop-over-gap` | `1rem` | Space between the trigger and the popover panel |
|
|
8
8
|
| `--pop-over-trigger-outline-width` | `0.1rem` | Trigger focus outline width |
|
|
9
9
|
| `--pop-over-trigger-outline-colour-hover` | `var(--theme-ring)` | Trigger outline colour on focus-visible |
|
|
10
|
-
| `--pop-over-width` | `30rem` | Popover panel width |
|
|
10
|
+
| `--pop-over-width` | `30rem` | Popover panel width, capped at the viewport width minus `3.2rem` |
|
|
11
|
+
| `--pop-over-max-block-size` | `calc(100dvh - 3.2rem)` | Popover panel maximum height; taller content scrolls inside the panel |
|
|
11
12
|
| `--pop-over-border-width` | `0.1rem` | Popover panel border width |
|
|
12
13
|
| `--pop-over-border-colour` | `var(--slate-02)` | Popover panel border colour |
|
|
13
14
|
| `--pop-over-border-radius` | `0.8rem` | Popover panel corner radius |
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
| `--pop-over-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
|
|
19
20
|
| `--pop-over-content-padding` | `1.6rem` | Padding around the `content` slot |
|
|
20
21
|
| `--pop-over-content-padding-block-start` | `3.2rem` | Extra top padding so content clears the close button |
|
|
21
|
-
| `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's top
|
|
22
|
+
| `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's block-start and inline-end edges (top right in left-to-right text, top left in right-to-left) |
|
|
22
23
|
| `--pop-over-close-button-size` | `2.4rem` | Close button box width/height |
|
|
23
24
|
| `--pop-over-close-button-border-radius` | `50%` | Close button corner radius |
|
|
24
25
|
| `--pop-over-close-button-colour` | `var(--slate-06)` | Close button icon colour |
|
|
@@ -27,6 +28,41 @@
|
|
|
27
28
|
| `--pop-over-close-button-surface-hover` | `var(--slate-01)` | Close button background on hover/focus-visible |
|
|
28
29
|
| `--pop-over-close-button-outline-colour-hover` | `var(--theme-ring)` | Close button outline colour on hover/focus-visible |
|
|
29
30
|
|
|
31
|
+
Private tokens (not public API): `--_anchor-name` (the generated CSS anchor name), and `--_anchor-top`, `--_anchor-right`, `--_anchor-bottom`, `--_anchor-left`, and their `-inverse` forms (measured trigger edges, set by `useAnchoredPopover` only where CSS anchor positioning is unsupported).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## State hooks
|
|
36
|
+
|
|
37
|
+
| Hook | Where | Meaning |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `[data-placement]` | `.pop-over` root | The `placement` prop: `top`, `right`, `bottom` or `left` |
|
|
40
|
+
| `[data-placement]` | `.pop-over-popover` | The resolved side, which can be the opposite of the prop when there is no room (only set where CSS anchor positioning is unsupported) |
|
|
41
|
+
| `:popover-open` | `.pop-over-popover` | Panel open, where the Popover API exists |
|
|
42
|
+
| `.pop-over-popover-open` | `.pop-over-popover` | Panel open on Safari 16 (no Popover API). Style it in a separate rule from `:popover-open` |
|
|
43
|
+
| `[role="dialog"]` | `.pop-over-popover` | Present only when `popoverAriaLabel` is set |
|
|
44
|
+
|
|
45
|
+
Inner class names: `.pop-over-trigger` (the trigger `<button>`), `.pop-over-popover` (the panel),
|
|
46
|
+
`.pop-over-close-button` (an `InputButton variant="inline"`, so also `.input-button`),
|
|
47
|
+
`.pop-over-close-button-icon`, `.pop-over-content` (wraps the `content` slot).
|
|
48
|
+
|
|
49
|
+
> Changed 2026-10-19: the close button is now an `InputButton` (`variant="inline"`, icon-only, with
|
|
50
|
+
> the `closeButtonAriaLabel` text visually hidden) instead of a raw `<button>`, and sits at the
|
|
51
|
+
> inline end, so it moves to the left in right-to-left text. The panel now caps at the viewport
|
|
52
|
+
> width and height and wraps unbroken strings.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Local overrides
|
|
57
|
+
|
|
58
|
+
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
59
|
+
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
60
|
+
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
61
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
62
|
+
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
63
|
+
|
|
64
|
+
### Page or section
|
|
65
|
+
|
|
30
66
|
```css
|
|
31
67
|
.my-page {
|
|
32
68
|
--pop-over-background-colour: #1a1a1a;
|
|
@@ -35,7 +71,7 @@
|
|
|
35
71
|
}
|
|
36
72
|
```
|
|
37
73
|
|
|
38
|
-
|
|
74
|
+
### One instance
|
|
39
75
|
|
|
40
76
|
```vue
|
|
41
77
|
<PopOver style-class-passthrough="promo-pop-over">...</PopOver>
|
|
@@ -43,13 +79,10 @@ Or scope to a single instance via `styleClassPassthrough`:
|
|
|
43
79
|
|
|
44
80
|
---
|
|
45
81
|
|
|
46
|
-
##
|
|
82
|
+
## Class passthrough
|
|
47
83
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
51
|
-
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
52
|
-
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
84
|
+
`style-class-passthrough` adds classes to the `.pop-over` root, which wraps both the trigger and the
|
|
85
|
+
panel, so tokens set on that class reach the panel too.
|
|
53
86
|
|
|
54
87
|
---
|
|
55
88
|
|
|
@@ -28,21 +28,25 @@
|
|
|
28
28
|
:class="{ 'pop-over-popover-open': usesFallbackPopover && isOpen }"
|
|
29
29
|
:style="positionStyle"
|
|
30
30
|
:data-placement="popoverPlacement"
|
|
31
|
+
:role="popoverAriaLabel ? 'dialog' : undefined"
|
|
31
32
|
:aria-label="popoverAriaLabel || undefined"
|
|
32
33
|
@beforetoggle="handleBeforeToggle"
|
|
33
34
|
@toggle="handleToggle"
|
|
34
35
|
>
|
|
35
|
-
<
|
|
36
|
-
|
|
36
|
+
<InputButton
|
|
37
|
+
type="button"
|
|
38
|
+
variant="inline"
|
|
37
39
|
:popovertarget="popoverId"
|
|
38
40
|
popovertargetaction="hide"
|
|
39
|
-
|
|
40
|
-
class="pop-over-close-button"
|
|
41
|
+
:button-text="closeButtonAriaLabel"
|
|
41
42
|
:aria-label="closeButtonAriaLabel"
|
|
43
|
+
style-class-passthrough="pop-over-close-button"
|
|
42
44
|
@click="hide"
|
|
43
45
|
>
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
+
<template #iconOnly>
|
|
47
|
+
<Icon :name="closeIcon" class="pop-over-close-button-icon" aria-hidden="true" />
|
|
48
|
+
</template>
|
|
49
|
+
</InputButton>
|
|
46
50
|
<div class="pop-over-content">
|
|
47
51
|
<slot name="content"></slot>
|
|
48
52
|
</div>
|
|
@@ -56,10 +60,12 @@ interface Props {
|
|
|
56
60
|
placement?: "top" | "right" | "bottom" | "left";
|
|
57
61
|
/** aria-label on the trigger button — set this when the trigger slot is icon-only. */
|
|
58
62
|
triggerAriaLabel?: string;
|
|
59
|
-
/**
|
|
63
|
+
/** Names the panel and gives it role="dialog"; set this when the content slot has no visible heading. */
|
|
60
64
|
popoverAriaLabel?: string;
|
|
61
65
|
/** aria-label on the close button — override for localisation. */
|
|
62
66
|
closeButtonAriaLabel?: string;
|
|
67
|
+
/** Iconify name for the close button icon. */
|
|
68
|
+
closeIcon?: string;
|
|
63
69
|
styleClassPassthrough?: string | string[];
|
|
64
70
|
}
|
|
65
71
|
|
|
@@ -68,6 +74,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
68
74
|
triggerAriaLabel: "",
|
|
69
75
|
popoverAriaLabel: "",
|
|
70
76
|
closeButtonAriaLabel: "Close",
|
|
77
|
+
closeIcon: "lucide:x",
|
|
71
78
|
styleClassPassthrough: () => [],
|
|
72
79
|
});
|
|
73
80
|
|
|
@@ -78,7 +85,6 @@ const anchorName = `--pop-over-anchor-${id}`;
|
|
|
78
85
|
const rootRef = ref<HTMLDivElement | null>(null);
|
|
79
86
|
const triggerRef = ref<HTMLButtonElement | null>(null);
|
|
80
87
|
const popoverRef = ref<HTMLDivElement | null>(null);
|
|
81
|
-
const closeButtonRef = ref<HTMLButtonElement | null>(null);
|
|
82
88
|
|
|
83
89
|
// Focus moves to the close button on open; closing returns it to the trigger in every mode.
|
|
84
90
|
const {
|
|
@@ -95,7 +101,7 @@ const {
|
|
|
95
101
|
triggerRef,
|
|
96
102
|
popoverRef,
|
|
97
103
|
side: () => props.placement,
|
|
98
|
-
onOpen: () =>
|
|
104
|
+
onOpen: () => popoverRef.value?.querySelector<HTMLElement>(".pop-over-close-button")?.focus(),
|
|
99
105
|
});
|
|
100
106
|
|
|
101
107
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
@@ -113,11 +119,14 @@ watch(
|
|
|
113
119
|
.pop-over {
|
|
114
120
|
display: inline-block;
|
|
115
121
|
position: relative;
|
|
122
|
+
max-inline-size: 100%;
|
|
116
123
|
|
|
117
124
|
.pop-over-trigger {
|
|
118
125
|
all: unset;
|
|
119
126
|
cursor: pointer;
|
|
120
127
|
anchor-name: var(--_anchor-name);
|
|
128
|
+
max-inline-size: 100%;
|
|
129
|
+
overflow-wrap: anywhere;
|
|
121
130
|
outline: var(--pop-over-trigger-outline-width, 0.1rem) solid transparent;
|
|
122
131
|
|
|
123
132
|
&:focus-visible {
|
|
@@ -141,7 +150,10 @@ watch(
|
|
|
141
150
|
--pop-over-shadow,
|
|
142
151
|
0 0.4rem 1.6rem rgba(0, 0, 0, 0.12)
|
|
143
152
|
);
|
|
144
|
-
width: var(--pop-over-width, 30rem);
|
|
153
|
+
width: min(var(--pop-over-width, 30rem), calc(100vw - 3.2rem));
|
|
154
|
+
max-block-size: var(--pop-over-max-block-size, calc(100dvh - 3.2rem));
|
|
155
|
+
overflow-y: auto;
|
|
156
|
+
overflow-wrap: anywhere;
|
|
145
157
|
|
|
146
158
|
opacity: 0;
|
|
147
159
|
transition:
|
|
@@ -169,8 +181,8 @@ watch(
|
|
|
169
181
|
all: unset;
|
|
170
182
|
cursor: pointer;
|
|
171
183
|
position: absolute;
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
inset-block-start: var(--pop-over-close-button-offset, 0.8rem);
|
|
185
|
+
inset-inline-end: var(--pop-over-close-button-offset, 0.8rem);
|
|
174
186
|
display: grid;
|
|
175
187
|
place-items: center;
|
|
176
188
|
width: var(--pop-over-close-button-size, 2.4rem);
|
|
@@ -186,6 +198,10 @@ watch(
|
|
|
186
198
|
outline-offset: 0.1rem;
|
|
187
199
|
}
|
|
188
200
|
|
|
201
|
+
.input-button-icon {
|
|
202
|
+
margin: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
189
205
|
.pop-over-close-button-icon {
|
|
190
206
|
width: var(--pop-over-close-button-icon-size, 1.4rem);
|
|
191
207
|
height: var(--pop-over-close-button-icon-size, 1.4rem);
|