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.
Files changed (73) hide show
  1. package/.claude/skills/components/breadcrumb.md +5 -0
  2. package/.claude/skills/components/display-pill.md +2 -0
  3. package/.claude/skills/components/input-button.md +3 -0
  4. package/.claude/skills/components/responsive-header.md +49 -0
  5. package/.claude/skills/components/service-detail.md +32 -0
  6. package/.claude/skills/components/service-summary.md +8 -0
  7. package/.claude/skills/components/services-card-grid.md +5 -3
  8. package/.claude/skills/components/services-card.md +22 -12
  9. package/.claude/skills/components/slider-gallery.md +10 -4
  10. package/.claude/skills/components/tab-navigation.md +25 -9
  11. package/.claude/skills/index.md +1 -2
  12. package/.vscode/srcdev-component-responsive-header.code-snippets +10 -0
  13. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  14. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  15. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  16. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  17. package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
  18. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
  19. package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
  20. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  21. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  22. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  23. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  24. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
  25. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
  26. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
  27. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
  28. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  29. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  30. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  31. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  32. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  33. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +4 -0
  34. package/app/components/03.organisms/responsive-header/NavigationItems.vue +33 -4
  35. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +51 -4
  36. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  37. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  38. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  39. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  40. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  41. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  42. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  43. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
  44. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  45. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  46. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  47. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  48. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  49. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  50. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  51. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  52. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  53. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  54. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  55. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  56. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  57. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  58. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  59. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  60. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  61. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  62. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  63. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  64. package/app/composables/useAnchorScroll.ts +1 -1
  65. package/nuxt.config.ts +1 -1
  66. package/package.json +1 -1
  67. package/.claude/skills/components/site-navigation.md +0 -145
  68. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  69. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  70. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  71. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  72. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  73. 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 items" :key="`${item.label}-${index}`" class="breadcrumb__item">
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 === items.length - 1 ? 'page' : undefined">{{
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 < items.length - 1" class="breadcrumb__separator" aria-hidden="true">{{ separator }}</span>
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 one without a `to`) |
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 `separator` are props. There is no hardcoded copy to override in CSS.
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 aria-current="page">` —
43
- omit `to` on whichever item represents the current page (usually the last one).
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(--slate-01, currentColor)` | Link text colour (rest state) |
13
- | `--tab-nav-link-hover-color` | `var(--slate-04, currentColor)` | Link text colour on hover |
14
- | `--tab-nav-link-active-color` | `var(--slate-01, currentColor)` | Link text colour when route is active |
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(--slate-01, currentColor)` | Active-item underline bar colour |
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, #1a1614)` | Panel background colour |
33
- | `--tab-nav-panel-border-color` | `color-mix(in oklch, var(--slate-01, #c0847a) 35%, transparent)` | Border between nav bar and open panel |
34
- | `--tab-nav-panel-item-border` | `color-mix(in oklch, var(--slate-01, white) 8%, transparent)` | Divider between panel items |
35
- | `--tab-nav-panel-link-color` | `var(--slate-01, currentColor)` | Panel link text colour |
36
- | `--tab-nav-panel-link-hover-color` | `var(--slate-04, currentColor)` | Panel link text colour on hover |
37
- | `--tab-nav-panel-link-active-color` | `var(--slate-01, currentColor)` | Panel active link colour |
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` | `var(--slate-01, currentColor)` | Burger bar colour |
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 navItemData.main"
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 ? 'Close navigation menu' : 'Open navigation menu'"
59
+ :button-text="isMenuOpen ? closeMenuLabel : openMenuLabel"
59
60
  :aria-expanded="String(isMenuOpen)"
60
- aria-controls="tab-nav-panel"
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="tab-nav-panel"
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 navItemData.main" :key="item.href" :class="item.cssName">
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 = props.navItemData.main?.find((item) => item.href?.startsWith("#"));
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(--slate-01, currentColor));
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-border-color: var(
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(--slate-01, currentColor));
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(--tab-nav-link-hover-color, var(--slate-04, var(--_link-color)));
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(--slate-01, var(--_link-color)));
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(--_panel-border-color);
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, #1a1614));
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 var(--tab-nav-panel-item-border, color-mix(in oklch, var(--slate-01, white) 8%, transparent));
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: color-mix(in oklch, var(--slate-01, white) 5%, transparent);
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(--slate-04, var(--_panel-link-color)));
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(--slate-01, var(--_panel-link-color)));
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(--slate-01, currentColor));
539
+ background-color: var(--tab-nav-decorator-indicator-color, var(--theme-accent));
508
540
  z-index: 3;
509
541
  }
510
542