srcdev-nuxt-components 9.3.7 → 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.
- 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 +156 -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 +49 -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 +181 -0
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -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,181 @@
|
|
|
1
|
+
# ServiceDetail — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
### Hero banner
|
|
6
|
+
|
|
7
|
+
| Token | Default | Controls |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero image |
|
|
10
|
+
| `--service-detail-hero-min-height` | `32rem` | Minimum height of the hero banner |
|
|
11
|
+
| `--service-detail-hero-padding` | `2.4rem` | Padding around the breadcrumb/title/pills content |
|
|
12
|
+
| `--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 |
|
|
13
|
+
| `--service-detail-hero-text-colour` | `white` | Colour of the breadcrumb, eyebrow, and title over the hero image |
|
|
14
|
+
| `--service-detail-breadcrumb-margin-block-end` | `0.8rem` | Space below the breadcrumb |
|
|
15
|
+
| `--service-detail-hero-pills-gap` | `0.8rem` | Gap between the duration and price pills |
|
|
16
|
+
| `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--theme-pill-bg`) |
|
|
17
|
+
| `--service-detail-hero-pill-border-colour` | `currentColor` | Border colour of the price/duration pills |
|
|
18
|
+
|
|
19
|
+
### Body layout
|
|
20
|
+
|
|
21
|
+
| Token | Default | Controls |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `--service-detail-body-gap` | `3rem` | Gap between the main column and sidebar |
|
|
24
|
+
| `--service-detail-body-margin-block-start` | `3rem` | Space above the two-column body |
|
|
25
|
+
| `--service-detail-main-section-gap` | `2.4rem` | Gap between stacked sections in the main column |
|
|
26
|
+
|
|
27
|
+
The body becomes two columns at a **900px container width** (`@container`, not viewport) — the
|
|
28
|
+
main column is flexible, the sidebar is `minmax(28rem, 34rem)`.
|
|
29
|
+
|
|
30
|
+
### Process / Ideal For / FAQs
|
|
31
|
+
|
|
32
|
+
The process list is rendered with `StepperList` (`indicator-variant="circle"`, connectors on) —
|
|
33
|
+
these tokens are `ServiceDetail`'s own names, mapped internally onto the matching
|
|
34
|
+
`--stepper-list-*` tokens scoped to `.service-detail__process`:
|
|
35
|
+
|
|
36
|
+
| Token | Default | Controls |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `--service-detail-process-step-gap` | `1.6rem` | Gap between the number circle and its text (`--stepper-list-gap`) |
|
|
39
|
+
| `--service-detail-process-step-padding-block` | `1.6rem` | Vertical padding per process step (`--stepper-list-padding-block`) |
|
|
40
|
+
| `--service-detail-process-divider-colour` | `currentColor` | Connector line between process steps (`--stepper-list-connector-color`) |
|
|
41
|
+
| `--service-detail-process-index-colour` | `var(--colour-text-accent)` | Colour and border of the step number circles (`--stepper-list-counter-circle-text`/`-border`) |
|
|
42
|
+
| `--service-detail-process-index-font-size` | `1.4rem` | Font size of the step numbers (`--stepper-list-counter-font-size`) |
|
|
43
|
+
|
|
44
|
+
For anything not covered by these (e.g. the circle's own size, `--stepper-list-counter-size`),
|
|
45
|
+
target `--stepper-list-*` directly under `.service-detail__process` — see
|
|
46
|
+
[stepper-list.md](../../../../../.claude/skills/components/stepper-list.md) for the full token API.
|
|
47
|
+
|
|
48
|
+
| Token | Default | Controls |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `--service-detail-ideal-for-gap` | `1.2rem` | Gap between ideal-for cards |
|
|
51
|
+
| `--service-detail-ideal-for-item-gap` | `1rem` | Gap between icon and text within a card |
|
|
52
|
+
| `--service-detail-ideal-for-item-padding` | `1.6rem` | Padding inside a card |
|
|
53
|
+
| `--service-detail-ideal-for-item-background` | `var(--slate-09)` | Card background colour |
|
|
54
|
+
| `--service-detail-ideal-for-item-border-colour` | `var(--slate-06)` | Card border colour |
|
|
55
|
+
| `--service-detail-ideal-for-item-border-radius` | `0.4rem` | Card corner rounding |
|
|
56
|
+
| `--service-detail-ideal-for-icon-size` | `1.6rem` | Icon size |
|
|
57
|
+
| `--service-detail-ideal-for-icon-colour` | `var(--colour-text-accent)` | Icon colour |
|
|
58
|
+
| `--service-detail-faq-padding-block` | `1.6rem` | Vertical padding per FAQ entry |
|
|
59
|
+
| `--service-detail-faq-divider-colour` | `currentColor` | Divider line between FAQ entries |
|
|
60
|
+
| `--service-detail-faq-question-font-size` | `1.6rem` | FAQ question font size |
|
|
61
|
+
| `--service-detail-faq-question-margin-block-end` | `0.8rem` | Space between an FAQ question and its answer |
|
|
62
|
+
|
|
63
|
+
The ideal-for grid becomes 2 columns at a **500px container width**.
|
|
64
|
+
|
|
65
|
+
### Sidebar
|
|
66
|
+
|
|
67
|
+
| Token | Default | Controls |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `--service-detail-sidebar-gap` | `2rem` | Gap between the booking card and related-services block |
|
|
70
|
+
| `--service-detail-sidebar-sticky-offset` | `2rem` | `top` offset while the sidebar is stuck |
|
|
71
|
+
| `--service-detail-sidebar-label-font-size` | `1.2rem` | Font size of "Book This Service" / "You May Also Like" labels |
|
|
72
|
+
| `--service-detail-sidebar-label-margin-block-end` | `1.6rem` | Space below a sidebar label |
|
|
73
|
+
| `--service-detail-sidebar-label-letter-spacing` | `0.05em` | Letter spacing of a sidebar label |
|
|
74
|
+
| `--service-detail-booking-card-padding` | `2.4rem` | Padding inside the booking `GlassPanel` |
|
|
75
|
+
| `--service-detail-sidebar-row-gap` | `1rem` | Gap between a row's label and value |
|
|
76
|
+
| `--service-detail-sidebar-row-padding-block` | `1rem` | Vertical padding per price/duration/location row |
|
|
77
|
+
| `--service-detail-sidebar-row-divider-colour` | `currentColor` | Divider line above each price/duration/location row |
|
|
78
|
+
| `--service-detail-sidebar-row-label-font-size` | `1.2rem` | Font size of a row's label (e.g. "Price") |
|
|
79
|
+
| `--service-detail-sidebar-note-font-size` | `1.2rem` | Font size of the `sidebar-note` slot content |
|
|
80
|
+
| `--service-detail-sidebar-note-margin-block-start` | `1.2rem` | Space above the `sidebar-note` slot content |
|
|
81
|
+
| `--service-detail-related-items-gap` | `1rem` | Gap between related-service item cards |
|
|
82
|
+
| `--service-detail-related-item-background` | `var(--slate-09)` | Background colour of a related-service item's card |
|
|
83
|
+
| `--service-detail-related-item-padding` | `1rem` | Padding inside a related-service item's card |
|
|
84
|
+
| `--service-detail-related-item-border-colour` | `var(--slate-06)` | Border colour of a related-service item's card |
|
|
85
|
+
| `--service-detail-related-item-border-radius` | `0.4rem` | Corner rounding of a related-service item's card |
|
|
86
|
+
| `--service-detail-related-item-gap` | `1.2rem` | Gap between a related item's thumbnail and its text |
|
|
87
|
+
| `--service-detail-related-image-size` | `5.6rem` | Width/height of a related-service thumbnail |
|
|
88
|
+
| `--service-detail-related-image-border-radius` | `0.4rem` | Corner rounding of a related-service thumbnail |
|
|
89
|
+
| `--service-detail-related-price-font-size` | `1.2rem` | Font size of a related item's price |
|
|
90
|
+
| `--service-detail-muted-opacity` | `0.7` | Opacity shared by every secondary/muted text element (sidebar label, sidebar note, related price, final CTA body) |
|
|
91
|
+
|
|
92
|
+
The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also apply.
|
|
93
|
+
|
|
94
|
+
### Final CTA banner
|
|
95
|
+
|
|
96
|
+
| Token | Default | Controls |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `--service-detail-final-cta-gap` | `1.6rem` | Gap between the copy and the `final-cta` slot content |
|
|
99
|
+
| `--service-detail-final-cta-margin-block-start` | `4rem` | Space above the banner |
|
|
100
|
+
| `--service-detail-final-cta-padding-block-start` | `3.2rem` | Padding above the banner content, below its divider |
|
|
101
|
+
| `--service-detail-final-cta-divider-colour` | `currentColor` | Divider line above the banner |
|
|
102
|
+
|
|
103
|
+
The banner becomes a single row (copy left, CTA right) at a **700px container width**.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Text content — props, not CSS
|
|
108
|
+
|
|
109
|
+
Section headings, sidebar labels, and CTA copy are **props**, not hardcoded strings:
|
|
110
|
+
|
|
111
|
+
| Prop | Default |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `processHeading` | `"The Process"` |
|
|
114
|
+
| `idealForHeading` | `"Ideal For"` |
|
|
115
|
+
| `maintenanceHeading` | `"Aftercare & Maintenance"` |
|
|
116
|
+
| `faqsHeading` | `"Frequently Asked Questions"` |
|
|
117
|
+
| `bookingHeading` | `"Book This Service"` |
|
|
118
|
+
| `priceLabel` | `"Price"` |
|
|
119
|
+
| `durationLabel` | `"Duration"` |
|
|
120
|
+
| `locationLabel` | `"Location"` |
|
|
121
|
+
| `relatedServicesHeading` | `"You May Also Like"` |
|
|
122
|
+
| `finalCtaHeading` | `"Ready to book your appointment?"` |
|
|
123
|
+
| `finalCtaBody` | `"Get in touch to book your appointment."` |
|
|
124
|
+
|
|
125
|
+
```vue
|
|
126
|
+
<ServiceDetail
|
|
127
|
+
:service-data="service"
|
|
128
|
+
final-cta-heading="Ready to book your colour appointment?"
|
|
129
|
+
final-cta-body="Mobile service across Bath — I come to you."
|
|
130
|
+
/>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Global theming — recommended approach
|
|
136
|
+
|
|
137
|
+
```css
|
|
138
|
+
/* assets/styles/setup/07.components/service-detail.css */
|
|
139
|
+
:root {
|
|
140
|
+
--service-detail-hero-border-radius: 1.2rem;
|
|
141
|
+
--service-detail-process-index-colour: var(--brand-accent);
|
|
142
|
+
--service-detail-ideal-for-icon-colour: var(--brand-accent);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Page-scoped overrides
|
|
147
|
+
|
|
148
|
+
```css
|
|
149
|
+
.our-services-page {
|
|
150
|
+
.service-detail {
|
|
151
|
+
--service-detail-body-gap: 4rem;
|
|
152
|
+
--service-detail-sidebar-sticky-offset: 8rem; /* clear a fixed site header */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Notes
|
|
160
|
+
|
|
161
|
+
- Routing is entirely delegated to the consumer via slots: `book-cta` (scoped `serviceData`),
|
|
162
|
+
`final-cta` (scoped `serviceData`), and `related-service` (scoped `service`, `index`) — same
|
|
163
|
+
pattern as `ServicesSection`'s `cta`/`summary-link` slots. Only `sidebar-note` has no scoped
|
|
164
|
+
data (it's plain content, e.g. patch-test wording).
|
|
165
|
+
- `related-service`'s fallback content is a non-clickable thumbnail + title + price — pass the
|
|
166
|
+
slot yourself to wrap it in a real link once you have a routing convention.
|
|
167
|
+
- The `location` row and the entire "You May Also Like" block only render when `location` /
|
|
168
|
+
`relatedServices` are actually passed — neither exists on the shared `Service` type, so nothing
|
|
169
|
+
breaks for consumer apps that don't have this data.
|
|
170
|
+
- `breadcrumbItems` defaults to a plain, non-linked `[category, title]` trail built from
|
|
171
|
+
`serviceData` — pass real `to` routes once your app's URL structure is known.
|
|
172
|
+
- `headerTag` (default `"h1"`) controls only the hero title; `subheadingTag` (default `"h2"`)
|
|
173
|
+
controls every other heading in the component (What Is It, Process, Ideal For, Aftercare, FAQs,
|
|
174
|
+
final CTA) — unlike `ServicesSection`, which uses one `headerTag` prop for all of them. This
|
|
175
|
+
component is meant to be the whole page's `<h1>`, so its subheadings need their own level.
|
|
176
|
+
- The FAQ question is a hardcoded `<h3>` — if you set `subheadingTag="h3"`, the FAQ questions will
|
|
177
|
+
sit at the same level as their own section heading rather than one below it.
|
|
178
|
+
- Two-/three-column breakpoints (900px body, 500px ideal-for grid, 700px final CTA) use
|
|
179
|
+
`@container` queries against the component's own width, not the viewport — they are not tokens
|
|
180
|
+
since resizing them would need corresponding `minmax()`/`grid-template-columns` changes too, not
|
|
181
|
+
just spacing (same rationale as `ServicesSection`'s `--services-section-grid-gap-desktop`).
|