srcdev-nuxt-components 9.3.6 → 9.3.8

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 (126) hide show
  1. package/.claude/hooks/check-component-update.sh +30 -1
  2. package/.claude/skills/components/accordian-core.md +7 -7
  3. package/.claude/skills/components/breadcrumb.md +49 -0
  4. package/.claude/skills/components/content-docs.md +2 -1
  5. package/.claude/skills/components/expanding-panel.md +5 -5
  6. package/.claude/skills/components/navigation-items.md +1 -0
  7. package/.claude/skills/components/responsive-header.md +1 -0
  8. package/.claude/skills/components/service-detail.md +156 -0
  9. package/.claude/skills/components/services-card-grid.md +9 -9
  10. package/.claude/skills/components/services-card.md +82 -30
  11. package/.claude/skills/index.md +3 -1
  12. package/.claude/skills/qa-panel.md +2 -2
  13. package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
  14. package/.vscode/srcdev-component-service-detail.code-snippets +49 -0
  15. package/.vscode/srcdev-component-services-card.code-snippets +108 -0
  16. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +22 -16
  17. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
  18. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
  19. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
  20. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
  21. package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
  22. package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +5 -1
  23. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +22 -14
  24. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +42 -28
  25. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +8 -10
  26. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +15 -47
  27. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +4 -12
  28. package/app/components/03.organisms/responsive-header/NavigationItems.vue +91 -4
  29. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +5 -1
  30. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +2 -2
  31. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +13 -13
  32. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +13 -13
  33. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +181 -0
  34. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
  35. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
  36. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
  37. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -0
  38. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
  39. package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
  40. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
  41. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
  42. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
  43. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
  44. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
  45. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
  46. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
  47. package/app/components/03.organisms/services/services-section/ServicesSection.vue +19 -0
  48. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +8 -8
  49. package/app/types/components/breadcrumb.ts +4 -0
  50. package/app/types/components/index.ts +1 -0
  51. package/package.json +2 -2
  52. package/app/pages/auto-grid.vue +0 -328
  53. package/app/pages/banner-video.vue +0 -238
  54. package/app/pages/forms/examples/buttons/index.vue +0 -259
  55. package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
  56. package/app/pages/forms/examples/material/cssbattle.vue +0 -60
  57. package/app/pages/forms/examples/material/text-fields.vue +0 -910
  58. package/app/pages/grid-stack.vue +0 -252
  59. package/app/pages/index.vue +0 -127
  60. package/app/pages/page-hero-highlights.vue +0 -204
  61. package/app/pages/samaritan.vue +0 -327
  62. package/app/pages/typography/hero-text.vue +0 -97
  63. package/app/pages/typography/index.vue +0 -39
  64. package/app/pages/typography/page-body.vue +0 -171
  65. package/app/pages/typography/page-heading.vue +0 -76
  66. package/app/pages/typography/page-link.vue +0 -103
  67. package/app/pages/ui/accordian.vue +0 -211
  68. package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
  69. package/app/pages/ui/anchor-scroll.vue +0 -367
  70. package/app/pages/ui/animated-svg-text.vue +0 -85
  71. package/app/pages/ui/block-decorators.vue +0 -142
  72. package/app/pages/ui/card-core.vue +0 -82
  73. package/app/pages/ui/carousel-basic.vue +0 -292
  74. package/app/pages/ui/carousel-flip.vue +0 -449
  75. package/app/pages/ui/carousel-infinite.vue +0 -260
  76. package/app/pages/ui/clipped-panels.vue +0 -101
  77. package/app/pages/ui/contact-section.vue +0 -323
  78. package/app/pages/ui/container-glow.vue +0 -106
  79. package/app/pages/ui/content-width.vue +0 -126
  80. package/app/pages/ui/deep-expanding-menu.vue +0 -138
  81. package/app/pages/ui/display-avatar.vue +0 -253
  82. package/app/pages/ui/display-banner.vue +0 -78
  83. package/app/pages/ui/display-chip.vue +0 -367
  84. package/app/pages/ui/display-details.vue +0 -171
  85. package/app/pages/ui/display-dialog.vue +0 -344
  86. package/app/pages/ui/display-pill.vue +0 -396
  87. package/app/pages/ui/display-prompt.vue +0 -284
  88. package/app/pages/ui/display-toast.vue +0 -311
  89. package/app/pages/ui/expanding-panel.vue +0 -326
  90. package/app/pages/ui/glowing-border.vue +0 -111
  91. package/app/pages/ui/layout-content-docs.vue +0 -72
  92. package/app/pages/ui/layout-grid-a.vue +0 -96
  93. package/app/pages/ui/layout-grid-b.vue +0 -132
  94. package/app/pages/ui/magnetic-navigation.vue +0 -50
  95. package/app/pages/ui/marquee-scroller.vue +0 -99
  96. package/app/pages/ui/mask-element.vue +0 -104
  97. package/app/pages/ui/masonry-columns.vue +0 -55
  98. package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
  99. package/app/pages/ui/masonry-grid-ordered.vue +0 -56
  100. package/app/pages/ui/masonry-grid-sorted.vue +0 -55
  101. package/app/pages/ui/masonry-grid.vue +0 -55
  102. package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
  103. package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
  104. package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
  105. package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
  106. package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
  107. package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
  108. package/app/pages/ui/navigation/tab-navigation.vue +0 -493
  109. package/app/pages/ui/page-row.vue +0 -127
  110. package/app/pages/ui/price-list.vue +0 -65
  111. package/app/pages/ui/profile-section.vue +0 -142
  112. package/app/pages/ui/qr-code/[componentName].vue +0 -218
  113. package/app/pages/ui/rotating-carousel.vue +0 -154
  114. package/app/pages/ui/scroll-reveal-image.vue +0 -483
  115. package/app/pages/ui/section-parallax.vue +0 -54
  116. package/app/pages/ui/services/colour-finder.vue +0 -43
  117. package/app/pages/ui/services/services-cards.vue +0 -57
  118. package/app/pages/ui/services/services-section/[slug].vue +0 -61
  119. package/app/pages/ui/services/services-sections-compact.vue +0 -64
  120. package/app/pages/ui/settings.vue +0 -54
  121. package/app/pages/ui/simple-grid.vue +0 -55
  122. package/app/pages/ui/slider-gallery.vue +0 -261
  123. package/app/pages/ui/tabs-y.vue +0 -107
  124. package/app/pages/ui/tabs.vue +0 -189
  125. package/app/pages/ui/tooltips.vue +0 -104
  126. package/app/pages/ui/wipe-away-vertical.vue +0 -129
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- A collapsible panel that lets you toggle component props live on a page — without touching the component or breaking the visual layout. Uses a native `<details>`/`<summary>` so it takes up no space when collapsed. Every page under `app/pages/ui/` in this library is a demo-only page already, so the panel always renders there no `isDev` gate needed. If you add one to a page in a consuming app that also serves real production traffic, gate it behind that app's own dev/staging check.
5
+ A collapsible panel that lets you toggle component props live on a page — without touching the component or breaking the visual layout. Uses a native `<details>`/`<summary>` so it takes up no space when collapsed. This library no longer ships its own demo pages (`app/pages/`) component demos live entirely in Storybook now, which has its own live prop-toggling via args/controls, so this panel isn't needed there. It's still useful in a **consuming app's own pages**: gate it behind that app's dev/staging check unless the page is genuinely demo-only.
6
6
 
7
7
  ## Structure
8
8
 
@@ -218,7 +218,7 @@ Scope inside your page body class (e.g. `.my-page`) so styles don't bleed. The p
218
218
 
219
219
  ## Notes
220
220
 
221
- - **Consuming apps on real production traffic**: if the page hosting the panel isn't demo-only (unlike everything under `app/pages/ui/` in this library), gate the panel with that app's own dev/staging check, e.g. `v-if="isDev"` with `const isDev = import.meta.dev;`. `import.meta.dev` is `false` in production builds, so the gated block is tree-shaken with no runtime cost.
221
+ - **Consuming apps on real production traffic**: if the page hosting the panel isn't demo-only, gate the panel with that app's own dev/staging check, e.g. `v-if="isDev"` with `const isDev = import.meta.dev;`. `import.meta.dev` is `false` in production builds, so the gated block is tree-shaken with no runtime cost.
222
222
  - **Consuming apps**: The active chip color (`oklch(55% 0.18 240)`) is a neutral blue. Replace with a brand accent token if preferred: `background: var(--color-brand-accent)`.
223
223
  - **Panel placement**: Outside any `overflow: hidden` or clipping container, otherwise the panel may be clipped or push layout unexpectedly. Placing it as a direct sibling of the component row works well.
224
224
  - **Computed CSS vars**: When a prop controls a CSS custom property (e.g. max-height tiers), use a `computed` that returns a style object and bind it with `:style` on the component wrapper:
@@ -0,0 +1,14 @@
1
+ {
2
+ "SRCDEV Breadcrumb": {
3
+ "description": "Breadcrumb trail — items without a `to` render as plain current-page text",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<Breadcrumb",
7
+ " :items=\"[",
8
+ " { label: '$1Services', to: '$2/services' },",
9
+ " { label: '$3${TM_FILENAME_BASE}' },",
10
+ " ]\"",
11
+ "/>"
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "SRCDEV ServiceDetail": {
3
+ "description": "ServiceDetail full page — hero banner, content, sticky sidebar, closing CTA",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<ServiceDetail",
7
+ " :service-data=\"$1service\"",
8
+ " location=\"$2Mobile — across Bath\"",
9
+ " :related-services=\"$3relatedServices\"",
10
+ ">",
11
+ " <template #book-cta>",
12
+ " <button type=\"button\">$4Book Now</button>",
13
+ " </template>",
14
+ " <template #sidebar-note>",
15
+ " $5",
16
+ " </template>",
17
+ " <template #final-cta>",
18
+ " <button type=\"button\">$6Book Now</button>",
19
+ " </template>",
20
+ "</ServiceDetail>"
21
+ ]
22
+ },
23
+ "SRCDEV ServiceDetail Custom Breadcrumb": {
24
+ "description": "ServiceDetail with explicit breadcrumb routes instead of the auto-built category/title trail",
25
+ "scope": "vue,html",
26
+ "body": [
27
+ "<ServiceDetail",
28
+ " :service-data=\"$1service\"",
29
+ " :breadcrumb-items=\"[",
30
+ " { label: '$2Services', to: '$3/services' },",
31
+ " { label: '$4${TM_FILENAME_BASE}' },",
32
+ " ]\"",
33
+ ">",
34
+ " <template #book-cta>",
35
+ " <button type=\"button\">$5Book Now</button>",
36
+ " </template>",
37
+ "</ServiceDetail>"
38
+ ]
39
+ },
40
+ "SRCDEV ServiceDetail Related Service Link Slot": {
41
+ "description": "ServiceDetail related-service scoped slot — wraps each related item in a real link",
42
+ "scope": "vue,html",
43
+ "body": [
44
+ "<template #related-service=\"{ service }\">",
45
+ " <NuxtLink :to=\"`/services/${service.slug}`\">$1{{ service.title }}</NuxtLink>",
46
+ "</template>"
47
+ ]
48
+ }
49
+ }
@@ -0,0 +1,108 @@
1
+ {
2
+ "SRCDEV ServicesCard Basic": {
3
+ "description": "ServicesCard basic usage with actions slot",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<ServicesCard :service-data=\"$1service\">",
7
+ " <template #actions=\"{ serviceData }\">",
8
+ " <InputButtonCore",
9
+ " variant=\"secondary\"",
10
+ " :button-text=\"`More about ${serviceData.title}`\"",
11
+ " :href=\"`/services/${serviceData.slug}`\"",
12
+ " >",
13
+ " <template #right>",
14
+ " <Icon name=\"mdi:arrow-right\" class=\"icon\" />",
15
+ " </template>",
16
+ " </InputButtonCore>",
17
+ " </template>",
18
+ "</ServicesCard>"
19
+ ]
20
+ },
21
+ "SRCDEV ServicesCard Whole Card Clickable": {
22
+ "description": "ServicesCard with href and no actions slot — whole card is clickable",
23
+ "scope": "vue,html",
24
+ "body": [
25
+ "<ServicesCard :service-data=\"$1service\" :href=\"`/services/${$1service.slug}`\" />"
26
+ ]
27
+ },
28
+ "SRCDEV ServicesCard With Config Overrides": {
29
+ "description": "ServicesCard with eyebrowConfig/heroConfig overrides",
30
+ "scope": "vue,html",
31
+ "body": [
32
+ "<ServicesCard",
33
+ " :service-data=\"$1service\"",
34
+ " :eyebrow-config=\"{ fontSize: '$2small' }\"",
35
+ " :hero-config=\"{ tag: '$3h3', fontSize: '$4title' }\"",
36
+ ">",
37
+ " <template #actions=\"{ serviceData }\">",
38
+ " <InputButtonCore variant=\"secondary\" button-text=\"$5Enquire\" :href=\"`/services/${serviceData.slug}`\" />",
39
+ " </template>",
40
+ "</ServicesCard>"
41
+ ]
42
+ },
43
+ "SRCDEV ServicesCard With Custom Meta Row": {
44
+ "description": "ServicesCard with custom duration/price slot content (e.g. icons)",
45
+ "scope": "vue,html",
46
+ "body": [
47
+ "<ServicesCard :service-data=\"$1service\">",
48
+ " <template #duration=\"{ serviceData }\">",
49
+ " <Icon name=\"mdi:clock-time-four-outline\" class=\"icon\" />",
50
+ " <span>{{ serviceData.duration }}</span>",
51
+ " </template>",
52
+ " <template #price=\"{ serviceData }\">",
53
+ " <Icon name=\"mdi:currency-gbp\" class=\"icon\" />",
54
+ " <span>From {{ serviceData.price }}</span>",
55
+ " </template>",
56
+ " <template #actions=\"{ serviceData }\">",
57
+ " <InputButtonCore variant=\"secondary\" button-text=\"$2Enquire\" :href=\"`/services/${serviceData.slug}`\" />",
58
+ " </template>",
59
+ "</ServicesCard>"
60
+ ]
61
+ },
62
+ "SRCDEV ServicesCard CSS Override": {
63
+ "description": "CSS override scaffold for ServicesCard — scope to a page or section class. See CONSUMER-STYLING.md for the full token list.",
64
+ "scope": "css",
65
+ "body": [
66
+ ".$1my-page {",
67
+ " .services-card {",
68
+ " /* Public tokens — could also be set globally on :root instead */",
69
+ " /* Interaction states — only visible in whole-card-clickable mode (is-clickable) */",
70
+ " --services-card-border-radius: ;",
71
+ " --services-card-border-width: ;",
72
+ " --services-card-border-colour: ;",
73
+ " --services-card-border-colour-hover: ;",
74
+ " --services-card-outline-width: ;",
75
+ " --services-card-outline-colour: ;",
76
+ " --services-card-outline-colour-hover: ;",
77
+ " --services-card-outline-offset: ;",
78
+ " --services-card-outline-offset-hover: ;",
79
+ "",
80
+ " --services-card-gap: ;",
81
+ " --image-wrapper-aspect-ratio: ;",
82
+ " --image-wrapper-border-radius: ;",
83
+ " --image-wrapper-padding-block: ;",
84
+ " --image-wrapper-padding-inline: ;",
85
+ " --image-wrapper-border-image-zoom-transform: ;",
86
+ " --details-wrapper-grid-gap: ;",
87
+ " --details-wrapper-padding-block: ;",
88
+ " --details-wrapper-padding-inline: ;",
89
+ " --description-padding-block: ;",
90
+ " --description-line-height: ;",
91
+ " --description-line-clamp: ;",
92
+ " --meta-padding-block: ;",
93
+ " --meta-text-transform: ;",
94
+ " --footer-padding-block: ;",
95
+ " --footer-wrapper-grid-gap: ;",
96
+ "",
97
+ " /* Local-only tokens — page-scoped override only, no :root fallback */",
98
+ " --_eyebrow-text-padding-block: ;",
99
+ " --_hero-text-padding-block: ;",
100
+ " --_description-text-colour: ;",
101
+ " --_meta-border-colour: ;",
102
+ " --_meta-text-colour: ;",
103
+ " --_meta-font-size: ;",
104
+ " }",
105
+ "}"
106
+ ]
107
+ }
108
+ }
@@ -5,7 +5,8 @@
5
5
  <slot name="docsContent"></slot>
6
6
  </div>
7
7
  <div v-if="hasDocsNav" class="docs-nav">
8
- <ExpandingPanel
8
+ <component
9
+ :is="panelComponent"
9
10
  v-model="docsNavPanelOpen"
10
11
  :name="docsNavPanelName"
11
12
  :animation-duration="200"
@@ -37,10 +38,11 @@
37
38
  </ul>
38
39
  </nav>
39
40
  </template>
40
- </ExpandingPanel>
41
+ </component>
41
42
  </div>
42
43
  <div v-if="hasDocsPageNav" class="docs-page-nav">
43
- <ExpandingPanel
44
+ <component
45
+ :is="panelComponent"
44
46
  v-model="docsPageNavPanelOpen"
45
47
  :name="docsPageNavPanelName"
46
48
  :animation-duration="200"
@@ -72,7 +74,7 @@
72
74
  </ul>
73
75
  </nav>
74
76
  </template>
75
- </ExpandingPanel>
77
+ </component>
76
78
  </div>
77
79
  </div>
78
80
  </component>
@@ -80,6 +82,8 @@
80
82
 
81
83
  <script setup lang="ts">
82
84
  import type { DocsNavItem } from "~/types/components";
85
+ import ExpandingPanel from "~/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue";
86
+ import ExpandingPanelClassic from "~/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue";
83
87
 
84
88
  interface Props {
85
89
  tag?: "div" | "section" | "article" | "main";
@@ -87,6 +91,7 @@ interface Props {
87
91
  docsPageNavItems?: DocsNavItem[];
88
92
  docsNavLabel?: string;
89
93
  docsPageNavLabel?: string;
94
+ panelVariant?: "modern" | "classic";
90
95
  styleClassPassthrough?: string | string[];
91
96
  }
92
97
  const props = withDefaults(defineProps<Props>(), {
@@ -95,9 +100,16 @@ const props = withDefaults(defineProps<Props>(), {
95
100
  docsPageNavItems: () => [],
96
101
  docsNavLabel: "Navigation",
97
102
  docsPageNavLabel: "On this page",
103
+ // Defaults to "classic" — the docs/admin nav toggle relies on contentIsOnTop on mobile, and
104
+ // ExpandingPanel's ::details-content-based positioning silently fails on WebKit (Safari/iOS),
105
+ // making the nav unreachable there. See AccordianCore's own variant prop and CLAUDE.md
106
+ // pitfall #19 for the full mechanism. "modern" is opt-in.
107
+ panelVariant: "classic",
98
108
  styleClassPassthrough: () => [],
99
109
  });
100
110
 
111
+ const panelComponent = computed(() => (props.panelVariant === "modern" ? ExpandingPanel : ExpandingPanelClassic));
112
+
101
113
  const activeNavItem = defineModel<string | undefined>("activeNavItem");
102
114
  const activePageNavItem = defineModel<string | undefined>("activePageNavItem");
103
115
 
@@ -238,12 +250,9 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${
238
250
  align-items: start;
239
251
 
240
252
  @container contentDocs (width >= 1024px) {
241
- .expanding-panel {
242
- & .expanding-panel-details {
243
- .expanding-panel-summary {
244
- cursor: default;
245
- }
246
- }
253
+ .expanding-panel-summary,
254
+ .expanding-panel-classic-summary {
255
+ cursor: default;
247
256
  }
248
257
  }
249
258
  }
@@ -256,12 +265,9 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${
256
265
  align-items: start;
257
266
 
258
267
  @container contentDocs (width >= 768px) {
259
- .expanding-panel {
260
- & .expanding-panel-details {
261
- .expanding-panel-summary {
262
- cursor: default;
263
- }
264
- }
268
+ .expanding-panel-summary,
269
+ .expanding-panel-classic-summary {
270
+ cursor: default;
265
271
  }
266
272
  }
267
273
  }
@@ -0,0 +1,94 @@
1
+ <template>
2
+ <nav class="breadcrumb" :class="[elementClasses]" aria-label="Breadcrumb">
3
+ <ol class="breadcrumb__list">
4
+ <li v-for="(item, index) in items" :key="`${item.label}-${index}`" class="breadcrumb__item">
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">{{
7
+ item.label
8
+ }}</span>
9
+ <span v-if="index < items.length - 1" class="breadcrumb__separator" aria-hidden="true">{{ separator }}</span>
10
+ </li>
11
+ </ol>
12
+ </nav>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import type { BreadcrumbItem } from "~/types/components/breadcrumb";
17
+
18
+ interface Props {
19
+ items: BreadcrumbItem[];
20
+ separator?: string;
21
+ styleClassPassthrough?: string | string[];
22
+ }
23
+
24
+ const props = withDefaults(defineProps<Props>(), {
25
+ separator: "/",
26
+ styleClassPassthrough: () => [],
27
+ });
28
+
29
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
30
+
31
+ watch(
32
+ () => props.styleClassPassthrough,
33
+ () => {
34
+ resetElementClasses(props.styleClassPassthrough);
35
+ }
36
+ );
37
+ </script>
38
+
39
+ <style lang="css">
40
+ @layer components {
41
+ .breadcrumb {
42
+ --_font-size: var(--breadcrumb-font-size, 1.3rem);
43
+ --_gap: var(--breadcrumb-gap, 0.8rem);
44
+ --_text-transform: var(--breadcrumb-text-transform, uppercase);
45
+ --_letter-spacing: var(--breadcrumb-letter-spacing, 0.05em);
46
+ --_colour: var(--breadcrumb-colour, currentColor);
47
+ --_colour-current: var(--breadcrumb-colour-current, var(--breadcrumb-colour, currentColor));
48
+ --_link-decoration-hover: var(--breadcrumb-link-decoration-hover, underline);
49
+
50
+ font-size: var(--_font-size);
51
+ text-transform: var(--_text-transform);
52
+ letter-spacing: var(--_letter-spacing);
53
+
54
+ .breadcrumb__list {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ align-items: center;
58
+ gap: var(--_gap);
59
+ margin: 0;
60
+ padding: 0;
61
+ list-style: none;
62
+ }
63
+
64
+ .breadcrumb__item {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: var(--_gap);
68
+ }
69
+
70
+ .breadcrumb__link,
71
+ .breadcrumb__label {
72
+ color: var(--_colour);
73
+ }
74
+
75
+ .breadcrumb__label[aria-current="page"] {
76
+ color: var(--_colour-current);
77
+ }
78
+
79
+ .breadcrumb__link {
80
+ text-decoration: none;
81
+
82
+ &:hover,
83
+ &:focus-visible {
84
+ text-decoration: var(--_link-decoration-hover);
85
+ }
86
+ }
87
+
88
+ .breadcrumb__separator {
89
+ color: var(--_colour);
90
+ opacity: 0.6;
91
+ }
92
+ }
93
+ }
94
+ </style>
@@ -0,0 +1,32 @@
1
+ # Breadcrumb — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ | Token | Default | Controls |
6
+ |---|---|---|
7
+ | `--breadcrumb-font-size` | `1.3rem` | Font size of the whole trail |
8
+ | `--breadcrumb-gap` | `0.8rem` | Gap between an item's label and its separator, and between items |
9
+ | `--breadcrumb-text-transform` | `uppercase` | Text transform of the whole trail |
10
+ | `--breadcrumb-letter-spacing` | `0.05em` | Letter spacing of the whole trail |
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`) |
13
+ | `--breadcrumb-link-decoration-hover` | `underline` | `text-decoration` on link hover/focus |
14
+
15
+ ```css
16
+ .breadcrumb {
17
+ --breadcrumb-colour: white;
18
+ --breadcrumb-colour-current: var(--colour-text-accent);
19
+ }
20
+ ```
21
+
22
+ ## Text content — props, not CSS
23
+
24
+ `items` and `separator` are props. There is no hardcoded copy to override in CSS.
25
+
26
+ ## Notes
27
+
28
+ - An item renders as a `NuxtLink` when it has a `to`, otherwise as `<span aria-current="page">` —
29
+ omit `to` on whichever item represents the current page (usually the last one).
30
+ - The separator (`aria-hidden="true"`) is only rendered between items, never after the last one.
31
+ - `--breadcrumb-colour` is what `ServiceDetail` overrides internally (to `white` by default) so
32
+ the breadcrumb reads over its hero image — see `ServiceDetail`'s `CONSUMER-STYLING.md`.
@@ -0,0 +1,87 @@
1
+ import Breadcrumb from "../Breadcrumb.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import type { BreadcrumbItem } from "~/types/components/breadcrumb";
4
+
5
+ const meta: Meta<typeof Breadcrumb> = {
6
+ title: "Atoms/Navigation/Breadcrumb",
7
+ component: Breadcrumb,
8
+ argTypes: {
9
+ items: {
10
+ control: "object",
11
+ description: "Ordered list of { label, to? } — items without `to` render as plain text (typically the last, current-page item)",
12
+ },
13
+ separator: {
14
+ control: "text",
15
+ description: "Character or short string rendered between items",
16
+ },
17
+ styleClassPassthrough: {
18
+ control: "object",
19
+ description: "Additional CSS classes applied to the root element",
20
+ },
21
+ },
22
+ args: {
23
+ separator: "/",
24
+ styleClassPassthrough: [],
25
+ },
26
+ parameters: {
27
+ docs: {
28
+ description: {
29
+ component:
30
+ "Renders a `<nav aria-label=\"Breadcrumb\">` trail from an items array. Any item with a `to` renders as a NuxtLink; an item without one renders as plain text marked `aria-current=\"page\"` — routing decisions stay with the consumer, same as the rest of this library.",
31
+ },
32
+ },
33
+ },
34
+ };
35
+
36
+ export default meta;
37
+ type Story = StoryObj<typeof Breadcrumb>;
38
+
39
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
40
+
41
+ const items: BreadcrumbItem[] = [
42
+ { label: "Services", to: "/services" },
43
+ { label: "Balayage" },
44
+ ];
45
+
46
+ // ─── Stories ──────────────────────────────────────────────────────────────────
47
+
48
+ export const Default: Story = {
49
+ args: { items },
50
+ render: (args) => ({
51
+ components: { Breadcrumb },
52
+ setup() {
53
+ return { args };
54
+ },
55
+ template: `<Breadcrumb v-bind="args" />`,
56
+ }),
57
+ };
58
+
59
+ export const ThreeLevels: Story = {
60
+ name: "Three Levels",
61
+ args: {
62
+ items: [
63
+ { label: "Home", to: "/" },
64
+ { label: "Services", to: "/services" },
65
+ { label: "Balayage" },
66
+ ],
67
+ },
68
+ render: (args) => ({
69
+ components: { Breadcrumb },
70
+ setup() {
71
+ return { args };
72
+ },
73
+ template: `<Breadcrumb v-bind="args" />`,
74
+ }),
75
+ };
76
+
77
+ export const CustomSeparator: Story = {
78
+ name: "Custom Separator",
79
+ args: { items, separator: ">" },
80
+ render: (args) => ({
81
+ components: { Breadcrumb },
82
+ setup() {
83
+ return { args };
84
+ },
85
+ template: `<Breadcrumb v-bind="args" />`,
86
+ }),
87
+ };
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import Breadcrumb from "../Breadcrumb.vue";
4
+ import type { BreadcrumbItem } from "~/types/components/breadcrumb";
5
+
6
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
7
+
8
+ const items: BreadcrumbItem[] = [
9
+ { label: "Services", to: "/services" },
10
+ { label: "Balayage", to: "/services/balayage" },
11
+ { label: "Highlights" },
12
+ ];
13
+
14
+ // ─── Tests ────────────────────────────────────────────────────────────────────
15
+
16
+ describe("Breadcrumb", () => {
17
+ it("mounts without error", async () => {
18
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
19
+ expect(wrapper.vm).toBeTruthy();
20
+ });
21
+
22
+ it("renders correct HTML structure", async () => {
23
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
24
+ expect(wrapper.html()).toMatchSnapshot();
25
+ });
26
+
27
+ it("renders a nav with the Breadcrumb aria-label", async () => {
28
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
29
+ expect(wrapper.attributes("aria-label")).toBe("Breadcrumb");
30
+ });
31
+
32
+ it("renders every item's label", async () => {
33
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
34
+ expect(wrapper.text()).toContain("Services");
35
+ expect(wrapper.text()).toContain("Balayage");
36
+ expect(wrapper.text()).toContain("Highlights");
37
+ });
38
+
39
+ it("renders a link for items with a to prop", async () => {
40
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
41
+ const links = wrapper.findAll("a");
42
+ expect(links.length).toBe(2);
43
+ expect(links[0]?.text()).toBe("Services");
44
+ expect(links[1]?.text()).toBe("Balayage");
45
+ });
46
+
47
+ it("renders the last item without a to prop as plain text marked as current page", async () => {
48
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
49
+ const current = wrapper.find('[aria-current="page"]');
50
+ expect(current.exists()).toBe(true);
51
+ expect(current.text()).toBe("Highlights");
52
+ });
53
+
54
+ it("does not render a separator after the last item", async () => {
55
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
56
+ const separators = wrapper.findAll(".breadcrumb__separator");
57
+ expect(separators.length).toBe(items.length - 1);
58
+ });
59
+
60
+ it("uses a custom separator", async () => {
61
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items, separator: ">" } });
62
+ expect(wrapper.find(".breadcrumb__separator").text()).toBe(">");
63
+ });
64
+
65
+ it("applies a single styleClassPassthrough string", async () => {
66
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items, styleClassPassthrough: "custom-class" } });
67
+ expect(wrapper.classes()).toContain("custom-class");
68
+ });
69
+
70
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
71
+ const wrapper = await mountSuspended(Breadcrumb, {
72
+ props: { items, styleClassPassthrough: ["class-a", "class-b"] },
73
+ });
74
+ expect(wrapper.classes()).toContain("class-a");
75
+ expect(wrapper.classes()).toContain("class-b");
76
+ });
77
+
78
+ it("updates classes when styleClassPassthrough prop changes", async () => {
79
+ const wrapper = await mountSuspended(Breadcrumb, {
80
+ props: { items, styleClassPassthrough: ["original"] },
81
+ });
82
+ expect(wrapper.classes()).toContain("original");
83
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
84
+ expect(wrapper.classes()).not.toContain("original");
85
+ expect(wrapper.classes()).toContain("updated");
86
+ });
87
+ });
@@ -0,0 +1,13 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Breadcrumb > renders correct HTML structure 1`] = `
4
+ "<nav class="breadcrumb" aria-label="Breadcrumb">
5
+ <ol class="breadcrumb__list">
6
+ <li class="breadcrumb__item"><a href="/services" class="breadcrumb__link">Services</a><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
7
+ <li class="breadcrumb__item"><a href="/services/balayage" class="breadcrumb__link">Balayage</a><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
8
+ <li class="breadcrumb__item"><span class="breadcrumb__label" aria-current="page">Highlights</span>
9
+ <!--v-if-->
10
+ </li>
11
+ </ol>
12
+ </nav>"
13
+ `;
@@ -34,11 +34,15 @@ interface Props {
34
34
  styleClassPassthrough?: string | string[];
35
35
  }
36
36
 
37
+ // Defaults to "classic" (not "modern") — ExpandingPanel's animation/positioning relies on
38
+ // ::details-content, which WebKit (Safari/iOS) doesn't support (see ExpandingPanel's own dev-mode
39
+ // warning and CLAUDE.md pitfall #19). "modern" is opt-in for consumers who've confirmed it works
40
+ // for their audience, not the safe default.
37
41
  const props = withDefaults(defineProps<Props>(), {
38
42
  name: undefined,
39
43
  itemCount: 0,
40
44
  animationDuration: 300,
41
- variant: "modern",
45
+ variant: "classic",
42
46
  styleClassPassthrough: () => [],
43
47
  });
44
48