srcdev-nuxt-components 9.5.7 → 9.6.0
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/skills/components/breadcrumb.md +5 -0
- package/.claude/skills/components/display-pill.md +2 -0
- package/.claude/skills/components/input-button.md +3 -0
- package/.claude/skills/components/responsive-header.md +49 -0
- package/.claude/skills/components/service-detail.md +32 -0
- package/.claude/skills/components/service-summary.md +8 -0
- package/.claude/skills/components/services-card-grid.md +5 -3
- package/.claude/skills/components/services-card.md +22 -12
- package/.claude/skills/components/slider-gallery.md +10 -4
- package/.claude/skills/components/tab-navigation.md +25 -9
- package/.claude/skills/index.md +1 -2
- package/.vscode/srcdev-component-responsive-header.code-snippets +10 -0
- package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
- package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
- package/.vscode/srcdev-component-services-card.code-snippets +38 -36
- package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
- package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
- package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
- package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
- package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
- package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +4 -0
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +33 -4
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +51 -4
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
- package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
- package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
- package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
- package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
- package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
- package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/input-button/InputButton.vue +3 -1
- package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
- package/app/composables/useAnchorScroll.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/site-navigation.md +0 -145
- package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
- package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
- package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
- package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
- package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<nav class="breadcrumb" :class="[elementClasses]" :aria-label="ariaLabel">
|
|
2
|
+
<nav v-if="visibleItems.length" class="breadcrumb" :class="[elementClasses]" :aria-label="ariaLabel">
|
|
3
3
|
<ol class="breadcrumb__list">
|
|
4
|
-
<li v-for="(item, index) in
|
|
4
|
+
<li v-for="(item, index) in visibleItems" :key="`${item.label}-${index}`" class="breadcrumb__item">
|
|
5
5
|
<NuxtLink v-if="item.to" :to="item.to" class="breadcrumb__link">{{ item.label }}</NuxtLink>
|
|
6
|
-
<span v-else class="breadcrumb__label" :aria-current="index ===
|
|
6
|
+
<span v-else class="breadcrumb__label" :aria-current="index === visibleItems.length - 1 ? 'page' : undefined">{{
|
|
7
7
|
item.label
|
|
8
8
|
}}</span>
|
|
9
|
-
<span v-if="index <
|
|
9
|
+
<span v-if="separator && index < visibleItems.length - 1" class="breadcrumb__separator" aria-hidden="true">{{ separator }}</span>
|
|
10
10
|
</li>
|
|
11
11
|
</ol>
|
|
12
12
|
</nav>
|
|
@@ -29,6 +29,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
29
29
|
styleClassPassthrough: () => [],
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// Blank labels would render an empty crumb with a dangling separator.
|
|
33
|
+
const visibleItems = computed(() => props.items.filter((item) => item.label?.trim()));
|
|
34
|
+
|
|
32
35
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
33
36
|
|
|
34
37
|
watch(
|
|
@@ -63,11 +66,19 @@ watch(
|
|
|
63
66
|
display: flex;
|
|
64
67
|
align-items: center;
|
|
65
68
|
gap: var(--_gap);
|
|
69
|
+
min-inline-size: 0;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
.breadcrumb__link,
|
|
69
73
|
.breadcrumb__label {
|
|
70
74
|
color: var(--_colour);
|
|
75
|
+
min-inline-size: 0;
|
|
76
|
+
overflow-wrap: anywhere;
|
|
77
|
+
display: -webkit-box;
|
|
78
|
+
-webkit-box-orient: vertical;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
-webkit-line-clamp: var(--breadcrumb-item-line-clamp, none);
|
|
81
|
+
line-clamp: var(--breadcrumb-item-line-clamp, none);
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
.breadcrumb__label[aria-current="page"] {
|
|
@@ -84,6 +95,7 @@ watch(
|
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
.breadcrumb__separator {
|
|
98
|
+
flex-shrink: 0;
|
|
87
99
|
color: var(--_colour);
|
|
88
100
|
opacity: 0.6;
|
|
89
101
|
}
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
| `--breadcrumb-text-transform` | `uppercase` | Text transform of the whole trail |
|
|
10
10
|
| `--breadcrumb-letter-spacing` | `0.05em` | Letter spacing of the whole trail |
|
|
11
11
|
| `--breadcrumb-colour` | `currentColor` | Colour of links, separators, and non-current labels |
|
|
12
|
-
| `--breadcrumb-colour-current` | `currentColor` | Colour of the current-page item (the
|
|
12
|
+
| `--breadcrumb-colour-current` | `currentColor` | Colour of the current-page item (the last item, when it has no `to`) |
|
|
13
13
|
| `--breadcrumb-link-decoration-hover` | `underline` | `text-decoration` on link hover/focus |
|
|
14
|
+
| `--breadcrumb-item-line-clamp` | `none` | Max lines of each item's label, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
|
|
15
|
+
|
|
16
|
+
> Added 2026-10-06: `--breadcrumb-item-line-clamp`; long unbroken labels now wrap inside the trail.
|
|
14
17
|
|
|
15
18
|
```css
|
|
16
19
|
.breadcrumb {
|
|
@@ -21,9 +24,21 @@
|
|
|
21
24
|
|
|
22
25
|
---
|
|
23
26
|
|
|
27
|
+
## State hooks
|
|
28
|
+
|
|
29
|
+
| Hook | When |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `.breadcrumb__label[aria-current="page"]` | The last item, when it has no `to` (styled by `--breadcrumb-colour-current`) |
|
|
32
|
+
|
|
33
|
+
Inner classes: `.breadcrumb__list`, `.breadcrumb__item`, `.breadcrumb__link`, `.breadcrumb__label`,
|
|
34
|
+
`.breadcrumb__separator`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
24
38
|
## Text content — props, not CSS
|
|
25
39
|
|
|
26
|
-
`items` and `
|
|
40
|
+
`items`, `separator` and `ariaLabel` (the nav landmark's label) are props. There is no hardcoded copy to
|
|
41
|
+
override in CSS.
|
|
27
42
|
|
|
28
43
|
---
|
|
29
44
|
|
|
@@ -37,11 +52,19 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
37
52
|
|
|
38
53
|
---
|
|
39
54
|
|
|
55
|
+
## Class passthrough
|
|
56
|
+
|
|
57
|
+
`style-class-passthrough` adds classes to the root `nav.breadcrumb`. Reactive after mount.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
40
61
|
## Notes
|
|
41
62
|
|
|
42
|
-
- An item renders as a `NuxtLink` when it has a `to`, otherwise as `<span
|
|
43
|
-
omit `to` on
|
|
44
|
-
- The separator (`aria-hidden="true"`) is only rendered between items, never after the last one
|
|
63
|
+
- An item renders as a `NuxtLink` when it has a `to`, otherwise as a plain `<span>`. The last item gets
|
|
64
|
+
`aria-current="page"` when it has no `to`, so omit `to` on the current page's item.
|
|
65
|
+
- The separator (`aria-hidden="true"`) is only rendered between items, never after the last one, and
|
|
66
|
+
not at all when `separator` is `""`.
|
|
67
|
+
- Items with a blank `label` are skipped, and no `<nav>` is rendered when none are left (added 2026-10-06).
|
|
45
68
|
- `--breadcrumb-colour` is what `ServiceDetail` overrides internally (to `white` by default) so
|
|
46
69
|
the breadcrumb reads over its hero image — see `ServiceDetail`'s `CONSUMER-STYLING.md`.
|
|
47
70
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
1
2
|
import Breadcrumb from "../Breadcrumb.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
|
|
2
4
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
5
|
import type { BreadcrumbItem } from "~/types/components/breadcrumb";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
4
7
|
|
|
5
8
|
const meta: Meta<typeof Breadcrumb> = {
|
|
6
9
|
title: "Atoms/Navigation/Breadcrumb",
|
|
@@ -14,6 +17,10 @@ const meta: Meta<typeof Breadcrumb> = {
|
|
|
14
17
|
control: "text",
|
|
15
18
|
description: "Character or short string rendered between items",
|
|
16
19
|
},
|
|
20
|
+
ariaLabel: {
|
|
21
|
+
control: "text",
|
|
22
|
+
description: "aria-label on the nav landmark; override for localisation",
|
|
23
|
+
},
|
|
17
24
|
styleClassPassthrough: {
|
|
18
25
|
control: "object",
|
|
19
26
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -21,6 +28,7 @@ const meta: Meta<typeof Breadcrumb> = {
|
|
|
21
28
|
},
|
|
22
29
|
args: {
|
|
23
30
|
separator: "/",
|
|
31
|
+
ariaLabel: "Breadcrumb",
|
|
24
32
|
styleClassPassthrough: [],
|
|
25
33
|
},
|
|
26
34
|
parameters: {
|
|
@@ -85,3 +93,89 @@ export const CustomSeparator: Story = {
|
|
|
85
93
|
template: `<Breadcrumb v-bind="args" />`,
|
|
86
94
|
}),
|
|
87
95
|
};
|
|
96
|
+
|
|
97
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
type LineClamp = "none" | "1" | "2" | "3";
|
|
100
|
+
|
|
101
|
+
// Story-only arg: drives a CSS token, not a Breadcrumb prop. See the argTypes entry.
|
|
102
|
+
type StressArgs = InstanceType<typeof Breadcrumb>["$props"] & { itemLineClamp: LineClamp };
|
|
103
|
+
|
|
104
|
+
const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
|
|
105
|
+
|
|
106
|
+
const stressItems: BreadcrumbItem[] = [
|
|
107
|
+
{ label: "Startseite", to: "/" },
|
|
108
|
+
{ label: `Kategorie ${longGerman}`, to: "/kategorie" },
|
|
109
|
+
{ label: "" },
|
|
110
|
+
{ label: " " },
|
|
111
|
+
{ label: "😀 Emoji first", to: "/emoji" },
|
|
112
|
+
{ label: "<b>not bold</b>", to: "/html" },
|
|
113
|
+
{ label: "تسريحات شعر جديدة لموسم الخريف", to: "/rtl" },
|
|
114
|
+
{ label: "https://example.com/a/very/long/url/that/never/breaks/because/it/has/no/spaces", to: "/url" },
|
|
115
|
+
{ label: "A", to: "/a" },
|
|
116
|
+
{ label: `${longGerman} und ${longGerman}` },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export const StressTest: StoryObj<StressArgs> = {
|
|
120
|
+
name: "Stress Test (Worst-Case Data)",
|
|
121
|
+
argTypes: {
|
|
122
|
+
itemLineClamp: {
|
|
123
|
+
control: "select",
|
|
124
|
+
options: ["none", "1", "2", "3"] satisfies LineClamp[],
|
|
125
|
+
description:
|
|
126
|
+
"**Story control, not a prop.** Sets the `--breadcrumb-item-line-clamp` CSS token on a wrapper so you can try it here. " +
|
|
127
|
+
"To use it in an app, set the token in your own CSS, e.g. `.page-header { --breadcrumb-item-line-clamp: 1; }`. " +
|
|
128
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
129
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
args: {
|
|
133
|
+
items: stressItems,
|
|
134
|
+
separator: "→→",
|
|
135
|
+
ariaLabel: "Navigationspfad",
|
|
136
|
+
itemLineClamp: "none",
|
|
137
|
+
},
|
|
138
|
+
render: (args) => ({
|
|
139
|
+
components: { Breadcrumb },
|
|
140
|
+
setup() {
|
|
141
|
+
const componentArgs = computed(() => {
|
|
142
|
+
const { itemLineClamp: _itemLineClamp, ...rest } = args;
|
|
143
|
+
return rest;
|
|
144
|
+
});
|
|
145
|
+
const tokenStyles = computed(() => ({ "--breadcrumb-item-line-clamp": args.itemLineClamp }));
|
|
146
|
+
return { componentArgs, tokenStyles };
|
|
147
|
+
},
|
|
148
|
+
template: `<div :style="tokenStyles"><Breadcrumb v-bind="componentArgs" /></div>`,
|
|
149
|
+
}),
|
|
150
|
+
decorators: [
|
|
151
|
+
(story, context) => ({
|
|
152
|
+
components: { story, CanvasSwitcher },
|
|
153
|
+
setup() {
|
|
154
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
155
|
+
return { canvasName };
|
|
156
|
+
},
|
|
157
|
+
template: `
|
|
158
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
159
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
160
|
+
</div>
|
|
161
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
162
|
+
<story />
|
|
163
|
+
</div>
|
|
164
|
+
`,
|
|
165
|
+
}),
|
|
166
|
+
],
|
|
167
|
+
parameters: {
|
|
168
|
+
layout: "fullscreen",
|
|
169
|
+
initialCanvas: "mobileCanvas",
|
|
170
|
+
docs: {
|
|
171
|
+
description: {
|
|
172
|
+
story:
|
|
173
|
+
"Deliberately hostile data to find breakage: ten items including a long German compound word, an unbroken URL, " +
|
|
174
|
+
"emoji, HTML-like text (must render as text), right-to-left Arabic, a single character, and two blank labels " +
|
|
175
|
+
"(skipped, with no dangling separator), plus a two-character separator and a German nav label. Check at every " +
|
|
176
|
+
"canvas width: labels wrap inside the trail, separators keep their size, and the last item is the current page. " +
|
|
177
|
+
"Try the Item line clamp control too: it\x27s a CSS token set by the story, not a prop.",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
@@ -84,4 +84,31 @@ describe("Breadcrumb", () => {
|
|
|
84
84
|
expect(wrapper.classes()).not.toContain("original");
|
|
85
85
|
expect(wrapper.classes()).toContain("updated");
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
// ─── Worst-case data ───────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
it("skips blank labels without leaving a dangling separator", async () => {
|
|
91
|
+
const wrapper = await mountSuspended(Breadcrumb, {
|
|
92
|
+
props: { items: [{ label: "Services", to: "/services" }, { label: " " }, { label: "Balayage" }, { label: "" }] },
|
|
93
|
+
});
|
|
94
|
+
expect(wrapper.findAll(".breadcrumb__item")).toHaveLength(2);
|
|
95
|
+
expect(wrapper.findAll(".breadcrumb__separator")).toHaveLength(1);
|
|
96
|
+
expect(wrapper.find(".breadcrumb__label").attributes("aria-current")).toBe("page");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("renders no nav landmark when there is nothing to show", async () => {
|
|
100
|
+
const wrapper = await mountSuspended(Breadcrumb, { props: { items: [{ label: "" }] } });
|
|
101
|
+
expect(wrapper.find("nav").exists()).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders no separator element when separator is empty", async () => {
|
|
105
|
+
const wrapper = await mountSuspended(Breadcrumb, { props: { items, separator: "" } });
|
|
106
|
+
expect(wrapper.find(".breadcrumb__separator").exists()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders HTML-like labels as text", async () => {
|
|
110
|
+
const wrapper = await mountSuspended(Breadcrumb, { props: { items: [{ label: "<b>bold</b>" }] } });
|
|
111
|
+
expect(wrapper.find(".breadcrumb__label").text()).toBe("<b>bold</b>");
|
|
112
|
+
expect(wrapper.find(".breadcrumb__label b").exists()).toBe(false);
|
|
113
|
+
});
|
|
87
114
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# TabNavigation — Consumer Styling Guide
|
|
2
2
|
|
|
3
|
+
> **Deprecated (2026-10-06):** use `ResponsiveHeader`. The token mapping is in "Migrating from
|
|
4
|
+
> TabNavigation" in `.claude/skills/components/responsive-header.md`.
|
|
5
|
+
|
|
3
6
|
## Public token API
|
|
4
7
|
|
|
5
8
|
All `--tab-nav-*` tokens are the stable override surface. Set them at any scope (global, page, or
|
|
@@ -9,32 +12,46 @@ instance) without touching the component itself.
|
|
|
9
12
|
|
|
10
13
|
| Token | Default | Controls |
|
|
11
14
|
|---|---|---|
|
|
12
|
-
| `--tab-nav-link-color` | `var(--
|
|
13
|
-
| `--tab-nav-link-hover-color` | `var(--
|
|
14
|
-
| `--tab-nav-link-active-color` |
|
|
15
|
+
| `--tab-nav-link-color` | `var(--theme-text)` | Link text colour (rest state) |
|
|
16
|
+
| `--tab-nav-link-hover-color` | `var(--theme-accent)` | Link text colour on hover |
|
|
17
|
+
| `--tab-nav-link-active-color` | the link colour | Link text colour when route is active |
|
|
15
18
|
| `--tab-nav-link-size` | `1.6rem` | Link font size |
|
|
16
19
|
| `--tab-nav-link-weight` | `400` | Link font weight |
|
|
17
20
|
| `--tab-nav-link-tracking` | `0.06em` | Link letter spacing |
|
|
18
21
|
| `--tab-nav-gap` | `2.2rem` | Gap between nav items |
|
|
19
22
|
| `--tab-nav-transition` | `250ms ease` | Colour transition on hover/active |
|
|
23
|
+
| `--tab-nav-focus-ring-width` | `2px` | Keyboard focus ring width, on bar and panel links |
|
|
24
|
+
| `--tab-nav-focus-ring-colour` | `currentColor` | Keyboard focus ring colour |
|
|
25
|
+
| `--tab-nav-focus-ring-offset` | `2px` | Focus ring gap on bar links (panel links draw it inset) |
|
|
26
|
+
|
|
27
|
+
> Changed 2026-10-06: keyboard focus now shows a ring. Before, `:focus-visible` only changed the
|
|
28
|
+
> link colour (`outline: none`).
|
|
29
|
+
|
|
30
|
+
> **Changed 2026-10-06: light defaults.** The colours used to assume a dark header (light `--slate-01`
|
|
31
|
+
> links, burger and indicator; dark `#1a1614` panel), so on a light page they were light-on-light.
|
|
32
|
+
> They now come from the light theme slots: links and burger `--theme-text`, hover and the indicator
|
|
33
|
+
> `--theme-accent`, the panel `--page-bg` (else `--theme-surface-subtle`), dividers mixed from the link
|
|
34
|
+
> colour. For a dark header, or dark mode, set the colour tokens yourself (e.g. with `light-dark()` in
|
|
35
|
+
> your own overrides); the library ships light values only. New: `--tab-nav-panel-item-hover-bg`.
|
|
20
36
|
|
|
21
37
|
### Indicator decorators
|
|
22
38
|
|
|
23
39
|
| Token | Default | Controls |
|
|
24
40
|
|---|---|---|
|
|
25
|
-
| `--tab-nav-decorator-indicator-color` | `var(--
|
|
41
|
+
| `--tab-nav-decorator-indicator-color` | `var(--theme-accent)` | Active-item underline bar colour |
|
|
26
42
|
| `--tab-nav-decorator-hovered-bg` | `transparent` | Background fill pill that follows the pointer |
|
|
27
43
|
|
|
28
44
|
### Mobile panel
|
|
29
45
|
|
|
30
46
|
| Token | Default | Controls |
|
|
31
47
|
|---|---|---|
|
|
32
|
-
| `--tab-nav-panel-bg` | `var(--page-bg,
|
|
33
|
-
| `--tab-nav-panel-border-color` |
|
|
34
|
-
| `--tab-nav-panel-item-border` |
|
|
35
|
-
| `--tab-nav-panel-
|
|
36
|
-
| `--tab-nav-panel-link-
|
|
37
|
-
| `--tab-nav-panel-link-
|
|
48
|
+
| `--tab-nav-panel-bg` | `var(--page-bg, var(--theme-surface-subtle))` | Panel background colour |
|
|
49
|
+
| `--tab-nav-panel-border-color` | the link colour at 20% | Border between nav bar and open panel |
|
|
50
|
+
| `--tab-nav-panel-item-border` | the panel link colour at 12% | Divider between panel items |
|
|
51
|
+
| `--tab-nav-panel-item-hover-bg` | the panel link colour at 5% | Panel item background on hover |
|
|
52
|
+
| `--tab-nav-panel-link-color` | the link colour | Panel link text colour |
|
|
53
|
+
| `--tab-nav-panel-link-hover-color` | the link hover colour | Panel link text colour on hover |
|
|
54
|
+
| `--tab-nav-panel-link-active-color` | the panel link colour | Panel active link colour |
|
|
38
55
|
| `--tab-nav-panel-padding-block` | `1.4rem` | Panel link vertical padding |
|
|
39
56
|
| `--tab-nav-panel-padding-inline` | `1.5rem` | Panel link horizontal padding |
|
|
40
57
|
| `--tab-nav-panel-slide-duration` | `350ms` | Panel open/close animation duration |
|
|
@@ -44,7 +61,7 @@ instance) without touching the component itself.
|
|
|
44
61
|
|
|
45
62
|
| Token | Default | Controls |
|
|
46
63
|
|---|---|---|
|
|
47
|
-
| `--tab-nav-burger-color` |
|
|
64
|
+
| `--tab-nav-burger-color` | the link colour | Burger bar colour |
|
|
48
65
|
| `--tab-nav-burger-width` | `22px` | Width of each burger bar |
|
|
49
66
|
| `--tab-nav-burger-height` | `1.5px` | Height of each burger bar |
|
|
50
67
|
| `--tab-nav-burger-gap` | `5px` | Gap between burger bars |
|
|
@@ -60,6 +77,24 @@ instance) without touching the component itself.
|
|
|
60
77
|
|
|
61
78
|
---
|
|
62
79
|
|
|
80
|
+
## State hooks
|
|
81
|
+
|
|
82
|
+
| Hook | When |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `.tab-navigation.is-collapsed` | The items don't fit, so the bar shows the burger instead |
|
|
85
|
+
| `.tab-navigation.menu-open`, `.tab-nav-burger.is-open`, `.tab-nav-panel.is-open`, `.tab-nav-backdrop.is-open` | The burger menu is open |
|
|
86
|
+
| `.tab-navigation.is-loaded` | First measurement done (the nav is hidden before it, to avoid a wrong-state flash) |
|
|
87
|
+
| `.tab-navigation.is-animated` | Indicator transitions are on (off for a frame during route changes) |
|
|
88
|
+
| `.tab-navigation--left` / `--center` / `--right` | The `navAlign` prop |
|
|
89
|
+
| `.tab-nav-list li.is-active` / `.is-hovered` | The active and hovered items (the indicators anchor to these) |
|
|
90
|
+
| `.tab-nav-link.router-link-exact-active` | Route link for the current page |
|
|
91
|
+
|
|
92
|
+
Inner classes: `.tab-nav-list`, `.tab-nav-link`, `.nav__hovered`, `.nav__active-indicator`, `.tab-nav-burger`,
|
|
93
|
+
`.burger-bar`, `.tab-nav-panel`, `.tab-nav-panel-inner`, `.tab-nav-panel-list`, `.tab-nav-panel-link`,
|
|
94
|
+
`.tab-nav-backdrop` (teleported to `<body>`). Each item's `cssName` is added to its `<li>`.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
63
98
|
## Global theming
|
|
64
99
|
|
|
65
100
|
Create `assets/styles/setup/07.components/tab-navigation.css` in the consuming app and set tokens
|
|
@@ -134,6 +169,12 @@ Override tokens for a single page by scoping them under a page wrapper. No `:dee
|
|
|
134
169
|
|
|
135
170
|
---
|
|
136
171
|
|
|
172
|
+
## Class passthrough
|
|
173
|
+
|
|
174
|
+
`style-class-passthrough` adds classes to the root `nav.tab-navigation`. Reactive after mount.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
137
178
|
## Notes
|
|
138
179
|
|
|
139
180
|
- The active underline indicator and hover pill use CSS Anchor Positioning. They animate via
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<nav
|
|
3
|
+
v-if="visibleItems.length"
|
|
3
4
|
ref="navRef"
|
|
4
5
|
class="tab-navigation"
|
|
5
6
|
:class="[
|
|
@@ -11,8 +12,8 @@
|
|
|
11
12
|
>
|
|
12
13
|
<ul v-if="!isCollapsed || !isLoaded" ref="navListRef" class="tab-nav-list" @mouseleave="hoveredItemHref = null">
|
|
13
14
|
<li
|
|
14
|
-
v-for="item in
|
|
15
|
-
:key="item.href"
|
|
15
|
+
v-for="(item, index) in visibleItems"
|
|
16
|
+
:key="`${index}-${item.href}`"
|
|
16
17
|
:data-href="item.href"
|
|
17
18
|
:class="[
|
|
18
19
|
item.cssName,
|
|
@@ -55,9 +56,9 @@
|
|
|
55
56
|
class="tab-nav-burger"
|
|
56
57
|
:class="{ 'is-open': isMenuOpen }"
|
|
57
58
|
variant="tertiary"
|
|
58
|
-
:button-text="isMenuOpen ?
|
|
59
|
+
:button-text="isMenuOpen ? closeMenuLabel : openMenuLabel"
|
|
59
60
|
:aria-expanded="String(isMenuOpen)"
|
|
60
|
-
aria-controls="
|
|
61
|
+
:aria-controls="panelId"
|
|
61
62
|
@click="toggleMenu"
|
|
62
63
|
>
|
|
63
64
|
<template #iconOnly>
|
|
@@ -79,14 +80,14 @@
|
|
|
79
80
|
|
|
80
81
|
<div
|
|
81
82
|
v-if="showCollapsed"
|
|
82
|
-
id="
|
|
83
|
+
:id="panelId"
|
|
83
84
|
class="tab-nav-panel"
|
|
84
85
|
:class="{ 'is-open': isMenuOpen }"
|
|
85
86
|
:inert="!isMenuOpen ? true : undefined"
|
|
86
87
|
>
|
|
87
88
|
<div class="tab-nav-panel-inner">
|
|
88
89
|
<ul class="tab-nav-panel-list">
|
|
89
|
-
<li v-for="item in
|
|
90
|
+
<li v-for="(item, index) in visibleItems" :key="`${index}-${item.href}`" :class="item.cssName">
|
|
90
91
|
<a
|
|
91
92
|
v-if="item.href?.startsWith('#')"
|
|
92
93
|
:href="item.href"
|
|
@@ -133,6 +134,10 @@ interface Props {
|
|
|
133
134
|
anchorScrollOffset?: number | (() => number);
|
|
134
135
|
/** aria-label on the nav landmark — override for localisation. */
|
|
135
136
|
ariaLabel?: string;
|
|
137
|
+
/** Burger button label while the menu is closed — override for localisation. */
|
|
138
|
+
openMenuLabel?: string;
|
|
139
|
+
/** Burger button label while the menu is open — override for localisation. */
|
|
140
|
+
closeMenuLabel?: string;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -140,8 +145,22 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
140
145
|
styleClassPassthrough: () => [],
|
|
141
146
|
anchorScrollOffset: undefined,
|
|
142
147
|
ariaLabel: "Site navigation",
|
|
148
|
+
openMenuLabel: "Open navigation menu",
|
|
149
|
+
closeMenuLabel: "Close navigation menu",
|
|
143
150
|
});
|
|
144
151
|
|
|
152
|
+
if (import.meta.dev) {
|
|
153
|
+
console.warn(
|
|
154
|
+
"TabNavigation is deprecated: use ResponsiveHeader, which also handles #anchor links " +
|
|
155
|
+
"(anchorScrollOffset) and collapses item by item. See \"Migrating from TabNavigation\" in " +
|
|
156
|
+
".claude/skills/components/responsive-header.md."
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Items with no text would render links with no accessible name.
|
|
161
|
+
const visibleItems = computed(() => (props.navItemData.main ?? []).filter((item) => item.text?.trim()));
|
|
162
|
+
const panelId = useId();
|
|
163
|
+
|
|
145
164
|
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu } =
|
|
146
165
|
useNavCollapse("tab-nav-loaded");
|
|
147
166
|
|
|
@@ -149,7 +168,7 @@ const { handleNavClick, activeHash } = useAnchorScroll({ offset: props.anchorScr
|
|
|
149
168
|
|
|
150
169
|
onMounted(() => {
|
|
151
170
|
if (!activeHash.value) {
|
|
152
|
-
const firstHashItem =
|
|
171
|
+
const firstHashItem = visibleItems.value.find((item) => item.href?.startsWith("#"));
|
|
153
172
|
if (firstHashItem?.href) activeHash.value = firstHashItem.href;
|
|
154
173
|
}
|
|
155
174
|
});
|
|
@@ -209,25 +228,22 @@ watch(
|
|
|
209
228
|
/* ─── Public token API ────────────────────────────────────────────── */
|
|
210
229
|
|
|
211
230
|
/* Horizontal nav */
|
|
212
|
-
--_link-color: var(--tab-nav-link-color, var(--
|
|
231
|
+
--_link-color: var(--tab-nav-link-color, var(--theme-text));
|
|
232
|
+
--_link-hover-color: var(--tab-nav-link-hover-color, var(--theme-accent));
|
|
213
233
|
--_link-size: var(--tab-nav-link-size, 1.6rem);
|
|
214
234
|
--_link-tracking: var(--tab-nav-link-tracking, 0.06em);
|
|
215
235
|
--_link-weight: var(--tab-nav-link-weight, 400);
|
|
216
236
|
--_nav-transition: var(--tab-nav-transition, 250ms ease);
|
|
217
237
|
|
|
218
238
|
/* Panel */
|
|
219
|
-
--_panel-
|
|
220
|
-
--tab-nav-panel-border-color,
|
|
221
|
-
color-mix(in oklch, var(--slate-01, #c0847a) 35%, transparent)
|
|
222
|
-
);
|
|
223
|
-
--_panel-link-color: var(--tab-nav-panel-link-color, var(--slate-01, currentColor));
|
|
239
|
+
--_panel-link-color: var(--tab-nav-panel-link-color, var(--_link-color));
|
|
224
240
|
--_panel-slide-duration: var(--tab-nav-panel-slide-duration, 350ms);
|
|
225
241
|
--_panel-slide-easing: var(--tab-nav-panel-slide-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
|
226
242
|
|
|
227
243
|
/* Burger */
|
|
228
244
|
--_burger-bar-height: var(--tab-nav-burger-height, 1.5px);
|
|
229
245
|
--_burger-bar-gap: var(--tab-nav-burger-gap, 5px);
|
|
230
|
-
--_burger-color: var(--tab-nav-burger-color, var(--
|
|
246
|
+
--_burger-color: var(--tab-nav-burger-color, var(--_link-color));
|
|
231
247
|
--_burger-transition: var(--tab-nav-burger-transition, 300ms ease);
|
|
232
248
|
|
|
233
249
|
/* ─────────────────────────────────────────────────────────────────── */
|
|
@@ -287,12 +303,17 @@ watch(
|
|
|
287
303
|
|
|
288
304
|
&:hover,
|
|
289
305
|
&:focus-visible {
|
|
290
|
-
color: var(--
|
|
306
|
+
color: var(--_link-hover-color);
|
|
291
307
|
outline: none;
|
|
292
308
|
}
|
|
293
309
|
|
|
310
|
+
&:focus-visible {
|
|
311
|
+
outline: var(--tab-nav-focus-ring-width, 2px) solid var(--tab-nav-focus-ring-colour, currentColor);
|
|
312
|
+
outline-offset: var(--tab-nav-focus-ring-offset, 2px);
|
|
313
|
+
}
|
|
314
|
+
|
|
294
315
|
&.router-link-exact-active {
|
|
295
|
-
color: var(--tab-nav-link-active-color, var(--
|
|
316
|
+
color: var(--tab-nav-link-active-color, var(--_link-color));
|
|
296
317
|
}
|
|
297
318
|
}
|
|
298
319
|
}
|
|
@@ -390,12 +411,12 @@ watch(
|
|
|
390
411
|
|
|
391
412
|
&.is-open {
|
|
392
413
|
grid-template-rows: 1fr;
|
|
393
|
-
border-block-start-color: var(--
|
|
414
|
+
border-block-start-color: var(--tab-nav-panel-border-color, color-mix(in oklch, var(--_link-color) 20%, transparent));
|
|
394
415
|
}
|
|
395
416
|
|
|
396
417
|
.tab-nav-panel-inner {
|
|
397
418
|
overflow: hidden;
|
|
398
|
-
background-color: var(--tab-nav-panel-bg, var(--page-bg,
|
|
419
|
+
background-color: var(--tab-nav-panel-bg, var(--page-bg, var(--theme-surface-subtle)));
|
|
399
420
|
}
|
|
400
421
|
|
|
401
422
|
.tab-nav-panel-list {
|
|
@@ -404,14 +425,18 @@ watch(
|
|
|
404
425
|
padding: 0;
|
|
405
426
|
|
|
406
427
|
li {
|
|
407
|
-
border-block-end: 1px solid
|
|
428
|
+
border-block-end: 1px solid
|
|
429
|
+
var(--tab-nav-panel-item-border, color-mix(in oklch, var(--_panel-link-color) 12%, transparent));
|
|
408
430
|
|
|
409
431
|
&:last-child {
|
|
410
432
|
border-block-end: none;
|
|
411
433
|
}
|
|
412
434
|
|
|
413
435
|
&:hover {
|
|
414
|
-
background-color:
|
|
436
|
+
background-color: var(
|
|
437
|
+
--tab-nav-panel-item-hover-bg,
|
|
438
|
+
color-mix(in oklch, var(--_panel-link-color) 5%, transparent)
|
|
439
|
+
);
|
|
415
440
|
}
|
|
416
441
|
}
|
|
417
442
|
|
|
@@ -426,18 +451,25 @@ watch(
|
|
|
426
451
|
text-decoration: none;
|
|
427
452
|
padding-block: var(--tab-nav-panel-padding-block, 1.4rem);
|
|
428
453
|
padding-inline: var(--tab-nav-panel-padding-inline, 1.5rem);
|
|
454
|
+
overflow-wrap: anywhere;
|
|
455
|
+
min-inline-size: 0;
|
|
429
456
|
position: relative;
|
|
430
457
|
z-index: 1;
|
|
431
458
|
transition: color var(--_nav-transition);
|
|
432
459
|
|
|
433
460
|
&:hover,
|
|
434
461
|
&:focus-visible {
|
|
435
|
-
color: var(--tab-nav-panel-link-hover-color, var(--
|
|
462
|
+
color: var(--tab-nav-panel-link-hover-color, var(--_link-hover-color));
|
|
436
463
|
outline: none;
|
|
437
464
|
}
|
|
438
465
|
|
|
466
|
+
&:focus-visible {
|
|
467
|
+
outline: var(--tab-nav-focus-ring-width, 2px) solid var(--tab-nav-focus-ring-colour, currentColor);
|
|
468
|
+
outline-offset: calc(-1 * var(--tab-nav-focus-ring-width, 2px));
|
|
469
|
+
}
|
|
470
|
+
|
|
439
471
|
&.router-link-exact-active {
|
|
440
|
-
color: var(--tab-nav-panel-link-active-color, var(--
|
|
472
|
+
color: var(--tab-nav-panel-link-active-color, var(--_panel-link-color));
|
|
441
473
|
}
|
|
442
474
|
}
|
|
443
475
|
}
|
|
@@ -504,7 +536,7 @@ watch(
|
|
|
504
536
|
bottom: 0;
|
|
505
537
|
height: 2px;
|
|
506
538
|
pointer-events: none;
|
|
507
|
-
background-color: var(--tab-nav-decorator-indicator-color, var(--
|
|
539
|
+
background-color: var(--tab-nav-decorator-indicator-color, var(--theme-accent));
|
|
508
540
|
z-index: 3;
|
|
509
541
|
}
|
|
510
542
|
|