srcdev-nuxt-components 9.3.7 → 9.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/check-component-update.sh +30 -1
- package/.claude/skills/components/breadcrumb.md +49 -0
- package/.claude/skills/components/content-docs.md +1 -1
- package/.claude/skills/components/service-detail.md +181 -0
- package/.claude/skills/components/services-card-grid.md +9 -9
- package/.claude/skills/components/services-card.md +82 -30
- package/.claude/skills/index.md +3 -1
- package/.claude/skills/qa-panel.md +2 -2
- package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
- package/.vscode/srcdev-component-service-detail.code-snippets +59 -0
- package/.vscode/srcdev-component-services-card.code-snippets +108 -0
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +234 -0
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +536 -0
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +306 -0
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +314 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +109 -0
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
- package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
- package/app/types/components/breadcrumb.ts +4 -0
- package/app/types/components/index.ts +1 -0
- package/package.json +2 -2
- package/app/pages/auto-grid.vue +0 -328
- package/app/pages/banner-video.vue +0 -238
- package/app/pages/forms/examples/buttons/index.vue +0 -259
- package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
- package/app/pages/forms/examples/material/cssbattle.vue +0 -60
- package/app/pages/forms/examples/material/text-fields.vue +0 -910
- package/app/pages/grid-stack.vue +0 -252
- package/app/pages/index.vue +0 -127
- package/app/pages/page-hero-highlights.vue +0 -204
- package/app/pages/samaritan.vue +0 -327
- package/app/pages/typography/hero-text.vue +0 -97
- package/app/pages/typography/index.vue +0 -39
- package/app/pages/typography/page-body.vue +0 -171
- package/app/pages/typography/page-heading.vue +0 -76
- package/app/pages/typography/page-link.vue +0 -103
- package/app/pages/ui/accordian.vue +0 -211
- package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
- package/app/pages/ui/anchor-scroll.vue +0 -367
- package/app/pages/ui/animated-svg-text.vue +0 -85
- package/app/pages/ui/block-decorators.vue +0 -142
- package/app/pages/ui/card-core.vue +0 -82
- package/app/pages/ui/carousel-basic.vue +0 -292
- package/app/pages/ui/carousel-flip.vue +0 -449
- package/app/pages/ui/carousel-infinite.vue +0 -260
- package/app/pages/ui/clipped-panels.vue +0 -101
- package/app/pages/ui/contact-section.vue +0 -323
- package/app/pages/ui/container-glow.vue +0 -106
- package/app/pages/ui/content-width.vue +0 -126
- package/app/pages/ui/deep-expanding-menu.vue +0 -138
- package/app/pages/ui/display-avatar.vue +0 -253
- package/app/pages/ui/display-banner.vue +0 -78
- package/app/pages/ui/display-chip.vue +0 -367
- package/app/pages/ui/display-details.vue +0 -171
- package/app/pages/ui/display-dialog.vue +0 -344
- package/app/pages/ui/display-pill.vue +0 -396
- package/app/pages/ui/display-prompt.vue +0 -284
- package/app/pages/ui/display-toast.vue +0 -311
- package/app/pages/ui/expanding-panel.vue +0 -326
- package/app/pages/ui/glowing-border.vue +0 -111
- package/app/pages/ui/layout-content-docs.vue +0 -72
- package/app/pages/ui/layout-grid-a.vue +0 -96
- package/app/pages/ui/layout-grid-b.vue +0 -132
- package/app/pages/ui/magnetic-navigation.vue +0 -50
- package/app/pages/ui/marquee-scroller.vue +0 -99
- package/app/pages/ui/mask-element.vue +0 -104
- package/app/pages/ui/masonry-columns.vue +0 -55
- package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
- package/app/pages/ui/masonry-grid-ordered.vue +0 -56
- package/app/pages/ui/masonry-grid-sorted.vue +0 -55
- package/app/pages/ui/masonry-grid.vue +0 -55
- package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
- package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
- package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
- package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
- package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
- package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
- package/app/pages/ui/navigation/tab-navigation.vue +0 -493
- package/app/pages/ui/page-row.vue +0 -127
- package/app/pages/ui/price-list.vue +0 -65
- package/app/pages/ui/profile-section.vue +0 -142
- package/app/pages/ui/qr-code/[componentName].vue +0 -218
- package/app/pages/ui/rotating-carousel.vue +0 -154
- package/app/pages/ui/scroll-reveal-image.vue +0 -483
- package/app/pages/ui/section-parallax.vue +0 -54
- package/app/pages/ui/services/colour-finder.vue +0 -43
- package/app/pages/ui/services/services-cards.vue +0 -57
- package/app/pages/ui/services/services-section/[slug].vue +0 -61
- package/app/pages/ui/services/services-sections-compact.vue +0 -64
- package/app/pages/ui/settings.vue +0 -54
- package/app/pages/ui/simple-grid.vue +0 -55
- package/app/pages/ui/slider-gallery.vue +0 -261
- package/app/pages/ui/tabs-y.vue +0 -107
- package/app/pages/ui/tabs.vue +0 -189
- package/app/pages/ui/tooltips.vue +0 -104
- package/app/pages/ui/wipe-away-vertical.vue +0 -129
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|
package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap
ADDED
|
@@ -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
|
+
`;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# ServiceDetail — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Layout building blocks
|
|
4
|
+
|
|
5
|
+
The hero banner is a [GridStack](../../../../../.claude/skills/components/grid-stack.md) with two
|
|
6
|
+
stacked layers — an `image` layer (the service photo) behind a `content` layer (the scrim +
|
|
7
|
+
breadcrumb/title/pills). Each layer's root element is itself a
|
|
8
|
+
[PageRow](../../../../../.claude/skills/components/page-row.md): `.service-detail__hero-image-row`
|
|
9
|
+
(prop `heroImageVariant`, default `"full"`) wraps the photo, and `.service-detail__hero-overlay`
|
|
10
|
+
(prop `heroContentVariant`, default `"content"`) *is* the scrim/alignment element, not a wrapper
|
|
11
|
+
around one — a `PageRow`'s own box always spans the full width of its parent regardless of variant,
|
|
12
|
+
only its children default into the variant's grid column, so the one element can carry the
|
|
13
|
+
full-bleed scrim while its child (`.service-detail__hero-content`) still gets pulled to the
|
|
14
|
+
configured column. This means the photo bleeds to whatever width `.service-detail` itself is allowed
|
|
15
|
+
(edge-to-edge if `ServiceDetail` is placed without a content-constraining wrapper around it), while
|
|
16
|
+
the breadcrumb/title/pills always stay pulled into the page's normal content-width column,
|
|
17
|
+
regardless of how wide the photo bleeds. `--service-detail-hero-padding` now only controls
|
|
18
|
+
**block** (vertical) padding on the scrim — horizontal alignment of the text is handled entirely by
|
|
19
|
+
`.service-detail__hero-overlay`'s own `PageRow` grid, not by padding, so it can't fight the page's
|
|
20
|
+
content-grid gutters.
|
|
21
|
+
|
|
22
|
+
The two-column body (main content + sticky sidebar) is wrapped in its own `PageRow`, whose variant
|
|
23
|
+
is a prop: `bodyVariant` (`"full" | "popout" | "content" | "inset-content"`, default `"content"`).
|
|
24
|
+
The closing full-width CTA banner is wrapped the same way via `finalCtaVariant` (same type, same
|
|
25
|
+
`"content"` default). This means a consumer no longer needs to wrap `ServiceDetail` itself in an
|
|
26
|
+
outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
|
|
27
|
+
directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
|
|
28
|
+
inside an outer, wider `PageRow` — see
|
|
29
|
+
[services-section.md](../../../../../.claude/skills/components/services-section.md)-style nesting
|
|
30
|
+
notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
|
|
31
|
+
compose.
|
|
32
|
+
|
|
33
|
+
## Public token API
|
|
34
|
+
|
|
35
|
+
### Hero banner
|
|
36
|
+
|
|
37
|
+
| Token | Default | Controls |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero (clips the image/content layers — set to `0` for a true edge-to-edge bleed hero) |
|
|
40
|
+
| `--service-detail-hero-content-align` | `end` | `align-content` of the content overlay grid — where the breadcrumb/title/pills sit within the hero's block axis (`end` = bottom-anchored) |
|
|
41
|
+
| `--service-detail-hero-min-height-mobile` | `32rem` | Hero banner height below the `768px` container breakpoint. The hero image always fills this height (`object-fit`/`object-position` control cropping) |
|
|
42
|
+
| `--service-detail-hero-min-height-tablet` | `36rem` | Hero banner height from the `768px` container breakpoint |
|
|
43
|
+
| `--service-detail-hero-min-height-desktop` | `42rem` | Hero banner height from the `1024px` container breakpoint |
|
|
44
|
+
| `--service-detail-hero-padding` | `3.2rem` | **Block-axis (vertical)** padding around the breadcrumb/title/pills content — horizontal alignment comes from the nested content `PageRow`, not this token |
|
|
45
|
+
| `--service-detail-hero-scrim` | `linear-gradient(0deg, rgb(0 0 0 / 70%) 0%, rgb(0 0 0 / 10%) 60%, transparent 100%)` | Overlay gradient behind the hero text, for legibility over the image |
|
|
46
|
+
| `--service-detail-hero-text-colour` | `white` | Colour of the breadcrumb, eyebrow, and title over the hero image |
|
|
47
|
+
| `--service-detail-breadcrumb-margin-block-end` | `0.8rem` | Space below the breadcrumb |
|
|
48
|
+
| `--service-detail-hero-pills-gap` | `0.8rem` | Gap between the duration and price pills |
|
|
49
|
+
| `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--theme-pill-bg`) |
|
|
50
|
+
| `--service-detail-hero-pill-border-colour` | `currentColor` | Border colour of the price/duration pills |
|
|
51
|
+
|
|
52
|
+
The hero image's crop behaviour also changes responsively (position only, not tokened as fit
|
|
53
|
+
itself): below the `768px` container breakpoint it's `object-fit: cover` anchored with
|
|
54
|
+
`--service-detail-hero-image-position-small` (default `bottom`); from `768px` up it switches to
|
|
55
|
+
`object-fit: contain` anchored with `--service-detail-hero-image-position-medium` (default
|
|
56
|
+
`center`) so the full photo stays visible once there's room for it, rather than continuing to crop.
|
|
57
|
+
|
|
58
|
+
For anything not covered by these (e.g. the `full`/`content` variant tracks themselves), target
|
|
59
|
+
`--page-row-*` tokens under `.service-detail__hero-image-row`/`.service-detail__hero-overlay` — see
|
|
60
|
+
[page-row.md](../../../../../.claude/skills/components/page-row.md).
|
|
61
|
+
|
|
62
|
+
### Body layout
|
|
63
|
+
|
|
64
|
+
| Token | Default | Controls |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `--service-detail-body-gap` | `3rem` | Gap between the main column and sidebar |
|
|
67
|
+
| `--service-detail-body-margin-block-start` | `3rem` | Space above the two-column body |
|
|
68
|
+
| `--service-detail-main-section-gap` | `2.4rem` | Gap between stacked sections in the main column |
|
|
69
|
+
|
|
70
|
+
The body becomes two columns at a **900px container width** (`@container`, not viewport) — the
|
|
71
|
+
main column is flexible, the sidebar is `minmax(28rem, 34rem)`. The body's own breakout width is
|
|
72
|
+
controlled by the `bodyVariant` prop (default `"content"`), not a token — see Layout building
|
|
73
|
+
blocks above.
|
|
74
|
+
|
|
75
|
+
### Process / Ideal For / FAQs
|
|
76
|
+
|
|
77
|
+
The process list is rendered with `StepperList` (`indicator-variant="circle"`, connectors on) —
|
|
78
|
+
these tokens are `ServiceDetail`'s own names, mapped internally onto the matching
|
|
79
|
+
`--stepper-list-*` tokens scoped to `.service-detail__process`:
|
|
80
|
+
|
|
81
|
+
| Token | Default | Controls |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `--service-detail-process-step-gap` | `1.6rem` | Gap between the number circle and its text (`--stepper-list-gap`) |
|
|
84
|
+
| `--service-detail-process-step-padding-block` | `1.6rem` | Vertical padding per process step (`--stepper-list-padding-block`) |
|
|
85
|
+
| `--service-detail-process-divider-colour` | `currentColor` | Connector line between process steps (`--stepper-list-connector-color`) |
|
|
86
|
+
| `--service-detail-process-index-colour` | `var(--colour-text-accent)` | Colour and border of the step number circles (`--stepper-list-counter-circle-text`/`-border`) |
|
|
87
|
+
| `--service-detail-process-index-font-size` | `1.4rem` | Font size of the step numbers (`--stepper-list-counter-font-size`) |
|
|
88
|
+
|
|
89
|
+
For anything not covered by these (e.g. the circle's own size, `--stepper-list-counter-size`),
|
|
90
|
+
target `--stepper-list-*` directly under `.service-detail__process` — see
|
|
91
|
+
[stepper-list.md](../../../../../.claude/skills/components/stepper-list.md) for the full token API.
|
|
92
|
+
|
|
93
|
+
| Token | Default | Controls |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `--service-detail-ideal-for-gap` | `1.2rem` | Gap between ideal-for cards |
|
|
96
|
+
| `--service-detail-ideal-for-item-gap` | `1rem` | Gap between icon and text within a card |
|
|
97
|
+
| `--service-detail-ideal-for-item-padding` | `1.6rem` | Padding inside a card |
|
|
98
|
+
| `--service-detail-ideal-for-item-background` | `var(--slate-09)` | Card background colour |
|
|
99
|
+
| `--service-detail-ideal-for-item-border-colour` | `var(--slate-06)` | Card border colour |
|
|
100
|
+
| `--service-detail-ideal-for-item-border-radius` | `0.4rem` | Card corner rounding |
|
|
101
|
+
| `--service-detail-ideal-for-icon-size` | `1.6rem` | Icon size |
|
|
102
|
+
| `--service-detail-ideal-for-icon-colour` | `var(--colour-text-accent)` | Icon colour |
|
|
103
|
+
| `--service-detail-ideal-for-icon-transform` | `translateY(0.4rem)` | Vertical nudge on the icon to optically align it with the first line of text |
|
|
104
|
+
| `--service-detail-faq-padding-block` | `1.6rem` | Vertical padding per FAQ entry |
|
|
105
|
+
| `--service-detail-faq-divider-colour` | `currentColor` | Divider line between FAQ entries |
|
|
106
|
+
| `--service-detail-faq-question-font-size` | `1.6rem` | FAQ question font size |
|
|
107
|
+
| `--service-detail-faq-question-margin-block-end` | `0.8rem` | Space between an FAQ question and its answer |
|
|
108
|
+
|
|
109
|
+
The ideal-for grid becomes 2 columns at a **500px container width**.
|
|
110
|
+
|
|
111
|
+
### Sidebar
|
|
112
|
+
|
|
113
|
+
| Token | Default | Controls |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `--service-detail-sidebar-gap` | `2rem` | Gap between the booking card and related-services block |
|
|
116
|
+
| `--service-detail-sidebar-sticky-offset` | `2rem` | `top` offset while the sidebar is stuck |
|
|
117
|
+
| `--service-detail-sidebar-label-font-size` | `1.2rem` | Font size of "Book This Service" / "You May Also Like" labels |
|
|
118
|
+
| `--service-detail-sidebar-label-margin-block-end` | `1.6rem` | Space below a sidebar label |
|
|
119
|
+
| `--service-detail-sidebar-label-letter-spacing` | `0.05em` | Letter spacing of a sidebar label |
|
|
120
|
+
| `--service-detail-booking-card-padding` | `2.4rem` | Padding inside the booking `GlassPanel` |
|
|
121
|
+
| `--service-detail-sidebar-row-gap` | `1rem` | Gap between a row's label and value |
|
|
122
|
+
| `--service-detail-sidebar-row-padding-block` | `1rem` | Vertical padding per price/duration/location row |
|
|
123
|
+
| `--service-detail-sidebar-row-divider-colour` | `currentColor` | Divider line above each price/duration/location row |
|
|
124
|
+
| `--service-detail-sidebar-row-book-cta-padding-block` | `2rem` | Vertical padding on the `book-cta` slot's own row (only renders when the slot has content) |
|
|
125
|
+
| `--service-detail-sidebar-row-book-cta-justify-content` | `end` | Horizontal alignment of the `book-cta` slot content within its row |
|
|
126
|
+
| `--service-detail-sidebar-row-label-font-size` | `1.2rem` | Font size of a row's label (e.g. "Price") |
|
|
127
|
+
| `--service-detail-sidebar-note-font-size` | `1.2rem` | Font size of the `sidebar-note` slot content |
|
|
128
|
+
| `--service-detail-sidebar-note-margin-block-start` | `1.2rem` | Space above the `sidebar-note` slot content |
|
|
129
|
+
| `--service-detail-related-items-gap` | `1rem` | Gap between related-service item cards |
|
|
130
|
+
| `--service-detail-related-item-background` | `var(--slate-09)` | Background colour of a related-service item's card |
|
|
131
|
+
| `--service-detail-related-item-padding` | `1rem` | Padding inside a related-service item's card |
|
|
132
|
+
| `--service-detail-related-item-border-colour` | `var(--slate-06)` | Border colour of a related-service item's card |
|
|
133
|
+
| `--service-detail-related-item-border-radius` | `0.4rem` | Corner rounding of a related-service item's card |
|
|
134
|
+
| `--service-detail-related-item-gap` | `1.2rem` | Gap between a related item's thumbnail and its text |
|
|
135
|
+
| `--service-detail-related-image-size` | `5.6rem` | Width/height of a related-service thumbnail |
|
|
136
|
+
| `--service-detail-related-image-border-radius` | `0.4rem` | Corner rounding of a related-service thumbnail |
|
|
137
|
+
| `--service-detail-related-price-font-size` | `1.2rem` | Font size of a related item's price |
|
|
138
|
+
| `--service-detail-related-price-margin-block-start` | `0.6rem` | Space between a related item's title and its price |
|
|
139
|
+
| `--service-detail-muted-opacity` | `0.7` | Opacity shared by every secondary/muted text element (sidebar label, sidebar note, related price, final CTA body) |
|
|
140
|
+
|
|
141
|
+
The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also apply.
|
|
142
|
+
|
|
143
|
+
### Final CTA banner
|
|
144
|
+
|
|
145
|
+
| Token | Default | Controls |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `--service-detail-final-cta-gap` | `1.6rem` | Gap between the copy and the `final-cta` slot content |
|
|
148
|
+
| `--service-detail-final-cta-margin-block-start` | `3.2rem` | Space above the banner |
|
|
149
|
+
| `--service-detail-final-cta-padding-block` | `3.2rem 3.2rem` | Padding (block-start block-end) around the banner content, below its divider |
|
|
150
|
+
| `--service-detail-final-cta-divider-colour` | `currentColor` | Divider line above the banner |
|
|
151
|
+
|
|
152
|
+
The banner becomes a single row (copy left, CTA right) at a **700px container width**. Its own
|
|
153
|
+
breakout width is controlled by the `finalCtaVariant` prop (default `"content"`), not a token —
|
|
154
|
+
see Layout building blocks above.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Text content — props, not CSS
|
|
159
|
+
|
|
160
|
+
Section headings, sidebar labels, and CTA copy are **props**, not hardcoded strings:
|
|
161
|
+
|
|
162
|
+
| Prop | Default |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `processHeading` | `"The Process"` |
|
|
165
|
+
| `idealForHeading` | `"Ideal For"` |
|
|
166
|
+
| `maintenanceHeading` | `"Aftercare & Maintenance"` |
|
|
167
|
+
| `faqsHeading` | `"Frequently Asked Questions"` |
|
|
168
|
+
| `bookingHeading` | `"Book This Service"` |
|
|
169
|
+
| `priceLabel` | `"Price"` |
|
|
170
|
+
| `durationLabel` | `"Duration"` |
|
|
171
|
+
| `locationLabel` | `"Location"` |
|
|
172
|
+
| `relatedServicesHeading` | `"You May Also Like"` |
|
|
173
|
+
| `finalCtaHeading` | `"Ready to book your appointment?"` |
|
|
174
|
+
| `finalCtaBody` | `"Get in touch to book your appointment."` |
|
|
175
|
+
|
|
176
|
+
```vue
|
|
177
|
+
<ServiceDetail
|
|
178
|
+
:service-data="service"
|
|
179
|
+
final-cta-heading="Ready to book your colour appointment?"
|
|
180
|
+
final-cta-body="Mobile service across Bath — I come to you."
|
|
181
|
+
/>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Global theming — recommended approach
|
|
187
|
+
|
|
188
|
+
```css
|
|
189
|
+
/* assets/styles/setup/07.components/service-detail.css */
|
|
190
|
+
:root {
|
|
191
|
+
--service-detail-hero-border-radius: 1.2rem;
|
|
192
|
+
--service-detail-process-index-colour: var(--brand-accent);
|
|
193
|
+
--service-detail-ideal-for-icon-colour: var(--brand-accent);
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Page-scoped overrides
|
|
198
|
+
|
|
199
|
+
```css
|
|
200
|
+
.our-services-page {
|
|
201
|
+
.service-detail {
|
|
202
|
+
--service-detail-body-gap: 4rem;
|
|
203
|
+
--service-detail-sidebar-sticky-offset: 8rem; /* clear a fixed site header */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Notes
|
|
211
|
+
|
|
212
|
+
- Routing is entirely delegated to the consumer via slots: `book-cta` (scoped `serviceData`),
|
|
213
|
+
`final-cta` (scoped `serviceData`), and `related-service` (scoped `service`, `index`) — same
|
|
214
|
+
pattern as `ServicesSection`'s `cta`/`summary-link` slots. Only `sidebar-note` has no scoped
|
|
215
|
+
data (it's plain content, e.g. patch-test wording).
|
|
216
|
+
- `related-service`'s fallback content is a non-clickable thumbnail + title + price — pass the
|
|
217
|
+
slot yourself to wrap it in a real link once you have a routing convention.
|
|
218
|
+
- The `location` row and the entire "You May Also Like" block only render when `location` /
|
|
219
|
+
`relatedServices` are actually passed — neither exists on the shared `Service` type, so nothing
|
|
220
|
+
breaks for consumer apps that don't have this data.
|
|
221
|
+
- The `book-cta` slot's own sidebar row also only renders when that slot actually has content
|
|
222
|
+
(`v-if="$slots['book-cta']"`) — it no longer reserves an empty row when the slot is unused.
|
|
223
|
+
- `breadcrumbItems` defaults to a plain, non-linked `[category, title]` trail built from
|
|
224
|
+
`serviceData` — pass real `to` routes once your app's URL structure is known.
|
|
225
|
+
- `headerTag` (default `"h1"`) controls only the hero title; `subheadingTag` (default `"h2"`)
|
|
226
|
+
controls every other heading in the component (What Is It, Process, Ideal For, Aftercare, FAQs,
|
|
227
|
+
final CTA) — unlike `ServicesSection`, which uses one `headerTag` prop for all of them. This
|
|
228
|
+
component is meant to be the whole page's `<h1>`, so its subheadings need their own level.
|
|
229
|
+
- The FAQ question is a hardcoded `<h3>` — if you set `subheadingTag="h3"`, the FAQ questions will
|
|
230
|
+
sit at the same level as their own section heading rather than one below it.
|
|
231
|
+
- Two-/three-column breakpoints (900px body, 500px ideal-for grid, 700px final CTA) use
|
|
232
|
+
`@container` queries against the component's own width, not the viewport — they are not tokens
|
|
233
|
+
since resizing them would need corresponding `minmax()`/`grid-template-columns` changes too, not
|
|
234
|
+
just spacing (same rationale as `ServicesSection`'s `--services-section-grid-gap-desktop`).
|