srcdev-nuxt-components 9.5.6 → 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 (113) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/breadcrumb.md +5 -0
  3. package/.claude/skills/components/display-pill.md +2 -0
  4. package/.claude/skills/components/input-button.md +3 -0
  5. package/.claude/skills/components/navigation-items.md +1 -0
  6. package/.claude/skills/components/opening-hours.md +12 -0
  7. package/.claude/skills/components/price-list.md +10 -1
  8. package/.claude/skills/components/responsive-header.md +74 -0
  9. package/.claude/skills/components/service-detail.md +32 -0
  10. package/.claude/skills/components/service-summary.md +8 -0
  11. package/.claude/skills/components/services-card-grid.md +5 -3
  12. package/.claude/skills/components/services-card.md +22 -12
  13. package/.claude/skills/components/site-header.md +2 -0
  14. package/.claude/skills/components/slider-gallery.md +10 -4
  15. package/.claude/skills/components/tab-navigation.md +25 -9
  16. package/.claude/skills/index.md +1 -2
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +13 -1
  19. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  20. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  21. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  22. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  23. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  24. package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
  25. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
  26. package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
  27. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  28. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  29. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  30. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  31. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
  32. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
  33. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
  34. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
  35. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  36. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  37. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  38. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  39. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  40. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  41. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  42. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  43. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  44. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  45. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  46. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  47. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  48. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  49. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  50. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  51. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +18 -0
  52. package/app/components/03.organisms/responsive-header/NavigationItems.vue +56 -18
  53. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +106 -31
  54. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +201 -18
  55. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  56. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  57. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  58. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  59. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  60. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  61. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  62. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  63. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  64. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  65. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  66. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  67. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  68. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  69. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  72. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +36 -0
  73. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +131 -6
  74. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  75. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  76. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  77. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  78. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  79. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  80. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
  81. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  82. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  83. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  84. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  85. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  86. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  87. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  88. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  89. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  90. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  91. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  92. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  93. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  94. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  95. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  96. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  97. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  98. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  99. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  100. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  101. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  102. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  103. package/app/composables/useAnchorScroll.ts +1 -1
  104. package/app/utils/opening-hours.ts +23 -2
  105. package/nuxt.config.ts +1 -1
  106. package/package.json +1 -1
  107. package/.claude/skills/components/site-navigation.md +0 -145
  108. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  109. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  110. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  111. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  112. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  113. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
@@ -85,6 +85,7 @@
85
85
  " --services-card-outline-width: ;",
86
86
  " --services-card-outline-colour: ;",
87
87
  " --services-card-outline-colour-hover: ;",
88
+ " --services-card-outline-colour-focus: ;",
88
89
  " --services-card-outline-offset: ;",
89
90
  " --services-card-outline-offset-hover: ;",
90
91
  " --services-card-background-color-hover: ;",
@@ -94,44 +95,45 @@
94
95
  " --services-card-background-color: ;",
95
96
  " --services-card-padding-block: ;",
96
97
  " --services-card-padding-inline: ;",
97
- " --image-wrapper-aspect-ratio: ;",
98
- " --image-wrapper-border-radius: ;",
99
- " --image-wrapper-padding-block: ;",
100
- " --image-wrapper-padding-inline: ;",
101
- " --image-wrapper-border-image-zoom-transform: ;",
102
- " --details-wrapper-grid-gap: ;",
103
- " --details-wrapper-padding-block: ;",
104
- " --details-wrapper-padding-inline: ;",
105
- " --eyebrow-text-padding-block: ;",
106
- " --hero-text-padding-block: ;",
107
- " --description-text-colour: ;",
108
- " --description-padding-block: ;",
109
- " --description-line-height: ;",
110
- " --description-font-weight: ;",
111
- " --description-line-clamp: ;",
112
- " --meta-padding-block: ;",
113
- " --meta-border-colour: ;",
114
- " --meta-text-colour: ;",
115
- " --meta-duration-text-colour: ;",
116
- " --meta-price-text-colour: ;",
117
- " --meta-duration-font-weight: ;",
118
- " --meta-price-font-weight: ;",
119
- " --meta-letter-spacing: ;",
120
- " --meta-font-size: ;",
121
- " --meta-text-transform: ;",
122
- " --footer-padding-block: ;",
123
- " --footer-wrapper-grid-gap: ;",
98
+ " --services-card-image-aspect-ratio: ;",
99
+ " --services-card-image-border-radius: ;",
100
+ " --services-card-image-padding-block: ;",
101
+ " --services-card-image-padding-inline: ;",
102
+ " --services-card-image-zoom-transform: ;",
103
+ " --services-card-details-gap: ;",
104
+ " --services-card-details-padding-block: ;",
105
+ " --services-card-details-padding-inline: ;",
106
+ " --services-card-eyebrow-padding-block: ;",
107
+ " --services-card-title-padding-block: ;",
108
+ " --services-card-description-text-colour: ;",
109
+ " --services-card-description-padding-block: ;",
110
+ " --services-card-description-line-height: ;",
111
+ " --services-card-description-font-weight: ;",
112
+ " --services-card-description-line-clamp: ;",
113
+ " --services-card-meta-padding-block: ;",
114
+ " --services-card-meta-border-colour: ;",
115
+ " --services-card-meta-text-colour: ;",
116
+ " --services-card-meta-duration-text-colour: ;",
117
+ " --services-card-meta-price-text-colour: ;",
118
+ " --services-card-meta-duration-font-weight: ;",
119
+ " --services-card-meta-price-font-weight: ;",
120
+ " --services-card-meta-letter-spacing: ;",
121
+ " --services-card-meta-font-size: ;",
122
+ " --services-card-meta-text-transform: ;",
123
+ " --services-card-meta-gap: ;",
124
+ " --services-card-footer-padding-block: ;",
125
+ " --services-card-footer-gap: ;",
124
126
  "",
125
127
  " /* Titles over the image (titlesWithinImageWrapper) */",
126
- " --image-wrapper-details-scrim-colour: ;",
127
- " --image-wrapper-details-scrim: ;",
128
- " --image-wrapper-details-text-colour: ;",
129
- " --image-wrapper-details-eyebrow-text-colour: ;",
130
- " --image-wrapper-details-gap: ;",
131
- " --image-wrapper-details-eyebrow-text-padding-block: ;",
132
- " --image-wrapper-details-eyebrow-text-padding-inline: ;",
133
- " --image-wrapper-details-hero-text-padding-block: ;",
134
- " --image-wrapper-details-hero-text-padding-inline: ;",
128
+ " --services-card-image-details-scrim-colour: ;",
129
+ " --services-card-image-details-scrim: ;",
130
+ " --services-card-image-details-text-colour: ;",
131
+ " --services-card-image-details-eyebrow-colour: ;",
132
+ " --services-card-image-details-gap: ;",
133
+ " --services-card-image-details-eyebrow-padding-block: ;",
134
+ " --services-card-image-details-eyebrow-padding-inline: ;",
135
+ " --services-card-image-details-title-padding-block: ;",
136
+ " --services-card-image-details-title-padding-inline: ;",
135
137
  " }",
136
138
  "}"
137
139
  ]
@@ -29,7 +29,8 @@
29
29
  ".$1my-page {",
30
30
  " --services-card-grid-column-min-width: $2;",
31
31
  " --services-card-grid-gap: $3;",
32
- " --service-summary-grid-row-gap: $4;",
32
+ " --services-card-grid-button-text-white-space: $4;",
33
+ " --service-summary-grid-row-gap: $5;",
33
34
  "}"
34
35
  ]
35
36
  }
@@ -40,9 +40,11 @@
40
40
  " main-nav-aria-label=\"${3:Main navigation}\"",
41
41
  " secondary-nav-aria-label=\"${4:Secondary navigation}\"",
42
42
  " overflow-menu-aria-label=\"${5:Overflow navigation menu}\"",
43
+ " overflow-button-label=\"${6:More navigation}\"",
44
+ " submenu-aria-label=\"${7:{title\\} submenu}\"",
43
45
  ">",
44
46
  " <template #branding>",
45
- " <NuxtLink to=\"/\" class=\"home-link\">$6Brand</NuxtLink>",
47
+ " <NuxtLink to=\"/\" class=\"home-link\">$8Brand</NuxtLink>",
46
48
  " </template>",
47
49
  "</SiteHeader>"
48
50
  ]
@@ -10,7 +10,7 @@
10
10
  "description": "TabNavigation with a localised aria-label",
11
11
  "scope": "vue,html",
12
12
  "body": [
13
- "<TabNavigation :nav-item-data=\"$1navData\" aria-label=\"${2:Site navigation}\" />"
13
+ "<TabNavigation :nav-item-data=\"$1navData\" aria-label=\"${2:Site navigation}\" open-menu-label=\"${3:Open navigation menu}\" close-menu-label=\"${4:Close navigation menu}\" />"
14
14
  ]
15
15
  },
16
16
  "SRCDEV TabNavigation Nav Data Script": {
@@ -73,6 +73,9 @@ keeps the same proportions. Tune a **ratio** to change spacing across all sizes,
73
73
  Private tokens (not public API): `--_background`, `--_text-colour`, `--_font-size`, `--_padding-inline`,
74
74
  `--_padding-block`, `--_padding-inline-icon` (derived from `--_icon-size`), `--_icon-size`, swapped by the size and variant classes.
75
75
 
76
+ > **Changed 2026-10-06**: a pill is capped at its container's width (`max-inline-size: 100%`), and a
77
+ > label too long for it ends in an ellipsis instead of overflowing. Short labels are unaffected.
78
+
76
79
  ## State hooks
77
80
 
78
81
  - Size class: `.sm`, `.md`, `.lg`. Variant class: `.default`, `.primary`, `.success`, `.warning`,
@@ -105,6 +105,7 @@ watch(
105
105
  line-height: 1;
106
106
  white-space: nowrap;
107
107
  width: fit-content;
108
+ max-inline-size: 100%;
108
109
  cursor: default;
109
110
  user-select: none;
110
111
 
@@ -128,8 +129,10 @@ watch(
128
129
  }
129
130
 
130
131
  .display-pill-label {
131
- display: inline-flex;
132
- align-items: center;
132
+ display: block;
133
+ min-inline-size: 0;
134
+ overflow-x: clip;
135
+ text-overflow: ellipsis;
133
136
  }
134
137
 
135
138
  &.sm {
@@ -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