srcdev-nuxt-components 9.3.11 → 9.3.12
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/skills/component-aria-landmark.md +3 -3
- package/.claude/skills/components/auto-grid.md +1 -1
- package/.claude/skills/components/page-hero-highlights.md +1 -1
- package/.claude/skills/components/service-detail.md +13 -14
- package/.claude/skills/components/service-summary-grid.md +141 -0
- package/.claude/skills/components/service-summary.md +165 -0
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/index.md +2 -2
- package/.vscode/srcdev-component-service-summary.code-snippets +67 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +7 -8
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +0 -1
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +2 -2
- package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +116 -0
- package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +183 -0
- package/app/components/03.organisms/services/{services-section/stories/ServicesSection.stories.ts → service-summary/stories/ServiceSummary.stories.ts} +78 -148
- package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +210 -0
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +17 -0
- package/app/components/03.organisms/services/services-grids/{ServicesSectionGrid.vue → ServiceSummaryGrid.vue} +8 -10
- package/app/components/03.organisms/services/services-grids/stories/{ServicesSectionGrid.stories.ts → ServiceSummaryGrid.stories.ts} +21 -21
- package/app/components/03.organisms/services/services-grids/tests/{ServicesSectionGrid.spec.ts → ServiceSummaryGrid.spec.ts} +28 -28
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +45 -0
- package/package.json +1 -1
- package/.claude/skills/components/services-section-grid.md +0 -130
- package/.claude/skills/components/services-section.md +0 -211
- package/.vscode/srcdev-component-services-section.code-snippets +0 -84
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +0 -87
- package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +0 -149
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +0 -345
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +0 -297
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +0 -87
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# ServiceSummary — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
All `--service-summary-*` tokens are the stable override surface. Set them globally in a
|
|
6
|
+
theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
|
|
7
|
+
|
|
8
|
+
### Layout & sizing
|
|
9
|
+
|
|
10
|
+
| Token | Default | Controls |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `--service-summary-grid-gap` | `2rem` | Gap between image and content columns below the 768px container breakpoint |
|
|
13
|
+
| `--service-summary-grid-gap-desktop` | `3rem` | Gap between columns at the 768px container breakpoint and above |
|
|
14
|
+
| `--service-summary-height-mobile` | `auto` | Height of the whole `.service-summary` row below the 768px container breakpoint. `auto` (the default) lets the row size to its content — the image sizes itself via `--service-summary-image-aspect-ratio` and the text column sizes to its own content. Set a fixed length to force both columns to share a common row height instead (the image then crops via `object-fit: cover` to fill it, and `alignment` positions the text within the leftover space) |
|
|
15
|
+
| `--service-summary-height-tablet` | `auto` (falls back to `--service-summary-height-mobile`) | Row height from the 768px container breakpoint |
|
|
16
|
+
| `--service-summary-height-desktop` | `auto` (falls back to `--service-summary-height-tablet`, then `-mobile`) | Row height from the 1024px container breakpoint |
|
|
17
|
+
| `--service-summary-image-aspect-ratio` | `1 / 1` | Aspect ratio of `.service-summary__image-wrapper` when its height isn't otherwise forced by a `--service-summary-height-*` override (i.e. the default, row-height-auto case) — has no visible effect once a row height override makes both dimensions definite |
|
|
18
|
+
| `--service-summary-image-padding-block-mobile` | `0` | Padding-block (top/bottom) inset around the image, inside `.service-summary__image-wrapper`, below the 768px container breakpoint — creates a visible frame/border effect since the image itself sizes to the wrapper's content box, not the padding box |
|
|
19
|
+
| `--service-summary-image-padding-block-tablet` | `0` (falls back to `-mobile`) | Padding-block from the 768px container breakpoint |
|
|
20
|
+
| `--service-summary-image-padding-block-desktop` | `0` (falls back to `-tablet`, then `-mobile`) | Padding-block from the 1024px container breakpoint |
|
|
21
|
+
| `--service-summary-image-padding-inline-mobile` | `0` | Padding-inline (left/right) inset around the image below the 768px container breakpoint |
|
|
22
|
+
| `--service-summary-image-padding-inline-tablet` | `0` (falls back to `-mobile`) | Padding-inline from the 768px container breakpoint |
|
|
23
|
+
| `--service-summary-image-padding-inline-desktop` | `0` (falls back to `-tablet`, then `-mobile`) | Padding-inline from the 1024px container breakpoint |
|
|
24
|
+
| `--service-summary-image-border-radius` | `0.8rem` | Corner rounding on the service image |
|
|
25
|
+
| `--service-summary-pills-gap` | `0.8rem` | Gap between the duration and price pills |
|
|
26
|
+
| `--service-summary-pills-margin-block-end` | `2rem` | Space below the pill row |
|
|
27
|
+
|
|
28
|
+
All breakpoints above (row height, image padding, and the two-column grid switch) are
|
|
29
|
+
**container queries** against `.service-summary`'s own inline size (`container-name:
|
|
30
|
+
service-summary`), not the viewport — this keeps the layout responsive to the space
|
|
31
|
+
`ServiceSummary` actually has, which matters when it sits next to a persistent side nav that a
|
|
32
|
+
viewport-based `@media` query can't see.
|
|
33
|
+
|
|
34
|
+
Below the 768px breakpoint the grid is a single stacked column (image above text), so a
|
|
35
|
+
`--service-summary-height-mobile` override sets a height for the *whole stack*, not just the
|
|
36
|
+
image — usually left `auto` on mobile and only set from `--service-summary-height-tablet` up,
|
|
37
|
+
where the layout is genuinely two columns side by side.
|
|
38
|
+
|
|
39
|
+
### Pill colours
|
|
40
|
+
|
|
41
|
+
The duration/price pills are `DisplayPill` instances — `ServiceSummary` maps its own tokens onto
|
|
42
|
+
`DisplayPill`'s `--theme-pill-*` custom properties scoped to `.service-summary__pills`:
|
|
43
|
+
|
|
44
|
+
| Token | Default | Controls |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--service-summary-pill-bg` | `transparent` | Background of the price/duration pills |
|
|
47
|
+
| `--service-summary-pill-colour` | `currentColor` | Text colour of the pills |
|
|
48
|
+
| `--service-summary-pill-border-colour` | `currentColor` | Border colour of the pills |
|
|
49
|
+
|
|
50
|
+
For anything not covered by these (e.g. pill size, font weight), target `--theme-pill-*` directly
|
|
51
|
+
under `.service-summary__pills` — see [display-pill.md](../../../../../.claude/skills/components/display-pill.md).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Global theming — recommended approach
|
|
56
|
+
|
|
57
|
+
Create `assets/styles/setup/07.components/service-summary.css` in the consuming app and
|
|
58
|
+
set tokens on `:root`. This applies to every `ServiceSummary` across the site.
|
|
59
|
+
|
|
60
|
+
```css
|
|
61
|
+
/* assets/styles/setup/07.components/service-summary.css */
|
|
62
|
+
:root {
|
|
63
|
+
--service-summary-grid-gap: 2.4rem;
|
|
64
|
+
--service-summary-grid-gap-desktop: 4rem;
|
|
65
|
+
--service-summary-image-border-radius: 1.2rem;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Page-scoped overrides
|
|
72
|
+
|
|
73
|
+
Override tokens for a specific section by scoping them under the page or layout wrapper.
|
|
74
|
+
No `:deep()` is required (component styles are unscoped).
|
|
75
|
+
|
|
76
|
+
```css
|
|
77
|
+
/* In the consuming page's unscoped <style> block */
|
|
78
|
+
.our-services-page {
|
|
79
|
+
.service-summary {
|
|
80
|
+
--service-summary-pill-border-colour: var(--brand-accent);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Per-instance overrides via styleClassPassthrough
|
|
88
|
+
|
|
89
|
+
Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
|
|
90
|
+
visual style, pass a modifier class:
|
|
91
|
+
|
|
92
|
+
```vue
|
|
93
|
+
<ServiceSummary :style-class-passthrough="['highlight-service']" :service-data="service" />
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```css
|
|
97
|
+
.service-summary.highlight-service {
|
|
98
|
+
--service-summary-image-border-radius: 2rem;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Notes
|
|
105
|
+
|
|
106
|
+
- `--service-summary-grid-gap-desktop` only applies at `768px` and above — the breakpoint
|
|
107
|
+
itself is not a token, since changing it would affect the grid's `minmax()` column sizing
|
|
108
|
+
too, not just spacing. The same 768px/1024px breakpoints are shared with the row-height
|
|
109
|
+
tokens above.
|
|
110
|
+
- The row-height tokens live on `.service-summary` itself (the whole row), not on
|
|
111
|
+
`.service-summary__image-wrapper` — `.service-summary__grid` and
|
|
112
|
+
`.service-summary__image-wrapper` both inherit it via `height: 100%`, so overriding the row
|
|
113
|
+
height also gives `alignment` real leftover space to position the text column within.
|
|
114
|
+
- `ServiceSummary` is summary-only — it has no "full mode". For a complete single-service page
|
|
115
|
+
(process, ideal-for list, FAQs, booking CTA), use
|
|
116
|
+
[ServiceDetail](../../../../../.claude/skills/components/service-detail.md) instead.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" class="service-summary" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
|
|
3
|
+
<div class="service-summary__grid" :class="{ 'service-summary__grid--reverse': reverse }">
|
|
4
|
+
<div class="service-summary__image-wrapper">
|
|
5
|
+
<NuxtImg
|
|
6
|
+
:src="serviceData.image"
|
|
7
|
+
:alt="serviceData.title"
|
|
8
|
+
:loading="imageLoading"
|
|
9
|
+
:fetchpriority="imageFetchPriority"
|
|
10
|
+
class="service-summary__image"
|
|
11
|
+
/>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="service-summary__info-wrapper" :class="infoWrapperClasses">
|
|
14
|
+
<EyebrowText font-size="large" :text-content="serviceData.subtitle" />
|
|
15
|
+
<HeroText
|
|
16
|
+
:id="headingId"
|
|
17
|
+
:tag="headerTag"
|
|
18
|
+
font-size="title"
|
|
19
|
+
:text-content="[{ text: serviceData.title, styleClass: 'normal' }]"
|
|
20
|
+
:style-class-passthrough="['mb-20']"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<div class="service-summary__pills">
|
|
24
|
+
<DisplayPill :label="serviceData.duration" size="md" variant="neutral" />
|
|
25
|
+
<DisplayPill :label="`From ${serviceData.price}`" size="md" variant="neutral" />
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
|
|
29
|
+
|
|
30
|
+
<slot name="summary-link" :service-data="serviceData"></slot>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</component>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script setup lang="ts">
|
|
37
|
+
import type { Service } from "~/types/types.services";
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
tag?: "div" | "section" | "article" | "main";
|
|
41
|
+
headerTag?: "h1" | "h2" | "h3";
|
|
42
|
+
index?: number;
|
|
43
|
+
serviceData: Service;
|
|
44
|
+
alignment?: "start" | "center" | "end";
|
|
45
|
+
reverse?: boolean;
|
|
46
|
+
styleClassPassthrough?: string | string[];
|
|
47
|
+
}
|
|
48
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
49
|
+
tag: "div",
|
|
50
|
+
headerTag: "h2",
|
|
51
|
+
index: 0,
|
|
52
|
+
alignment: "center",
|
|
53
|
+
reverse: false,
|
|
54
|
+
styleClassPassthrough: () => [],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
|
|
58
|
+
|
|
59
|
+
const infoWrapperClasses = computed(() => ({
|
|
60
|
+
[`service-summary__info-wrapper--align-${props.alignment}`]: true,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
// Computed to return loading="lazy" if index is greater than 1, so that the first two summaries prioritise image loading, but if there are more than 2 summaries, the rest will lazy load their images to improve performance.
|
|
64
|
+
const imageLoading = computed(() => (props.index !== undefined && props.index > 1 ? "lazy" : "eager"));
|
|
65
|
+
|
|
66
|
+
// Only the first image (LCP candidate) gets fetchpriority="high" to reduce resource load delay.
|
|
67
|
+
const imageFetchPriority = computed(() => (props.index === 0 ? "high" : "auto"));
|
|
68
|
+
|
|
69
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
70
|
+
|
|
71
|
+
watch(
|
|
72
|
+
() => props.styleClassPassthrough,
|
|
73
|
+
() => {
|
|
74
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<style lang="css">
|
|
80
|
+
@layer components {
|
|
81
|
+
.service-summary {
|
|
82
|
+
container-type: inline-size;
|
|
83
|
+
container-name: service-summary;
|
|
84
|
+
height: var(--service-summary-height-mobile, auto);
|
|
85
|
+
|
|
86
|
+
@container service-summary (width >= 768px) {
|
|
87
|
+
height: var(--service-summary-height-tablet, var(--service-summary-height-mobile, auto));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@container service-summary (width >= 1024px) {
|
|
91
|
+
height: var(
|
|
92
|
+
--service-summary-height-desktop,
|
|
93
|
+
var(--service-summary-height-tablet, var(--service-summary-height-mobile, auto))
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.service-summary__grid {
|
|
98
|
+
--_grid-gap: var(--service-summary-grid-gap, 2rem);
|
|
99
|
+
|
|
100
|
+
display: grid;
|
|
101
|
+
grid-template-columns: 1fr;
|
|
102
|
+
gap: var(--_grid-gap);
|
|
103
|
+
height: 100%;
|
|
104
|
+
|
|
105
|
+
@container service-summary (width >= 768px) {
|
|
106
|
+
--_grid-gap: var(--service-summary-grid-gap-desktop, 3rem);
|
|
107
|
+
|
|
108
|
+
grid-template-columns: repeat(auto-fit, minmax(246px, 1fr));
|
|
109
|
+
|
|
110
|
+
&.service-summary__grid--reverse {
|
|
111
|
+
.service-summary__image-wrapper {
|
|
112
|
+
/* The order: 2 on .service-summary__image-wrapper pushes it after the content div, since by default both children have order: 0 and source order wins — so the content div naturally sits first. */
|
|
113
|
+
order: 2;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.service-summary__info-wrapper {
|
|
119
|
+
display: grid;
|
|
120
|
+
|
|
121
|
+
&.service-summary__info-wrapper--align-start {
|
|
122
|
+
align-content: start;
|
|
123
|
+
}
|
|
124
|
+
&.service-summary__info-wrapper--align-center {
|
|
125
|
+
align-content: center;
|
|
126
|
+
}
|
|
127
|
+
&.service-summary__info-wrapper--align-end {
|
|
128
|
+
align-content: end;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.service-summary__image-wrapper {
|
|
133
|
+
height: 100%;
|
|
134
|
+
aspect-ratio: var(--service-summary-image-aspect-ratio, 1 / 1);
|
|
135
|
+
padding-block: var(--service-summary-image-padding-block-mobile, 0);
|
|
136
|
+
padding-inline: var(--service-summary-image-padding-inline-mobile, 0);
|
|
137
|
+
border-radius: var(--service-summary-image-border-radius, 0.8rem);
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
|
|
140
|
+
@container service-summary (width >= 768px) {
|
|
141
|
+
padding-block: var(
|
|
142
|
+
--service-summary-image-padding-block-tablet,
|
|
143
|
+
var(--service-summary-image-padding-block-mobile, 0)
|
|
144
|
+
);
|
|
145
|
+
padding-inline: var(
|
|
146
|
+
--service-summary-image-padding-inline-tablet,
|
|
147
|
+
var(--service-summary-image-padding-inline-mobile, 0)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@container service-summary (width >= 1024px) {
|
|
152
|
+
padding-block: var(
|
|
153
|
+
--service-summary-image-padding-block-desktop,
|
|
154
|
+
var(--service-summary-image-padding-block-tablet, var(--service-summary-image-padding-block-mobile, 0))
|
|
155
|
+
);
|
|
156
|
+
padding-inline: var(
|
|
157
|
+
--service-summary-image-padding-inline-desktop,
|
|
158
|
+
var(--service-summary-image-padding-inline-tablet, var(--service-summary-image-padding-inline-mobile, 0))
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.service-summary__image {
|
|
163
|
+
display: block;
|
|
164
|
+
width: 100%;
|
|
165
|
+
height: 100%;
|
|
166
|
+
object-fit: cover;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.service-summary__pills {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-wrap: wrap;
|
|
173
|
+
gap: var(--service-summary-pills-gap, 0.8rem);
|
|
174
|
+
margin-block-end: var(--service-summary-pills-margin-block-end, 2rem);
|
|
175
|
+
|
|
176
|
+
--theme-pill-bg: var(--service-summary-pill-bg, transparent);
|
|
177
|
+
--theme-pill-color: var(--service-summary-pill-colour, currentColor);
|
|
178
|
+
--theme-pill-border-color: var(--service-summary-pill-border-colour, currentColor);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
</style>
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import
|
|
1
|
+
import ServiceSummary from "../ServiceSummary.vue";
|
|
2
2
|
import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
|
|
3
3
|
import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
|
|
4
4
|
import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
|
|
5
5
|
import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
|
|
6
|
-
import InputButtonCore from "../../../../05.forms/input-button/InputButtonCore.vue";
|
|
7
6
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
8
7
|
import type { Service } from "~/types/types.services";
|
|
9
8
|
|
|
10
|
-
const meta: Meta<typeof
|
|
11
|
-
title: "Organisms/Services/
|
|
12
|
-
component:
|
|
9
|
+
const meta: Meta<typeof ServiceSummary> = {
|
|
10
|
+
title: "Organisms/Services/Service Summary",
|
|
11
|
+
component: ServiceSummary,
|
|
13
12
|
argTypes: {
|
|
14
13
|
tag: {
|
|
15
14
|
control: { type: "select" },
|
|
@@ -21,51 +20,15 @@ const meta: Meta<typeof ServicesSection> = {
|
|
|
21
20
|
options: ["h1", "h2", "h3"],
|
|
22
21
|
description: "Heading element used for the service title",
|
|
23
22
|
},
|
|
24
|
-
|
|
25
|
-
control: { type: "boolean" },
|
|
26
|
-
description: "Summary mode — compact view with summary-link slot",
|
|
27
|
-
},
|
|
28
|
-
summaryAlignment: {
|
|
23
|
+
alignment: {
|
|
29
24
|
control: { type: "select" },
|
|
30
25
|
options: ["start", "center", "end"],
|
|
31
|
-
description: "Vertical alignment of the info column
|
|
26
|
+
description: "Vertical alignment of the info column relative to the image",
|
|
32
27
|
},
|
|
33
28
|
reverse: {
|
|
34
29
|
control: { type: "boolean" },
|
|
35
30
|
description: "Swap image and content columns",
|
|
36
31
|
},
|
|
37
|
-
durationIcon: {
|
|
38
|
-
control: { type: "text" },
|
|
39
|
-
description: "Icon name for the duration row (any Iconify icon)",
|
|
40
|
-
},
|
|
41
|
-
priceIcon: {
|
|
42
|
-
control: { type: "text" },
|
|
43
|
-
description: "Icon name for the price row (any Iconify icon)",
|
|
44
|
-
},
|
|
45
|
-
processHeading: {
|
|
46
|
-
control: { type: "text" },
|
|
47
|
-
description: "Heading text for the process section",
|
|
48
|
-
},
|
|
49
|
-
idealForHeading: {
|
|
50
|
-
control: { type: "text" },
|
|
51
|
-
description: "Heading text for the ideal-for section",
|
|
52
|
-
},
|
|
53
|
-
maintenanceHeading: {
|
|
54
|
-
control: { type: "text" },
|
|
55
|
-
description: "Heading text for the maintenance/aftercare section",
|
|
56
|
-
},
|
|
57
|
-
faqsHeading: {
|
|
58
|
-
control: { type: "text" },
|
|
59
|
-
description: "Heading text for the FAQs section",
|
|
60
|
-
},
|
|
61
|
-
ctaHeading: {
|
|
62
|
-
control: { type: "text" },
|
|
63
|
-
description: "Heading text inside the default CTA glass panel — ignored if the cta-panel slot is used",
|
|
64
|
-
},
|
|
65
|
-
ctaBody: {
|
|
66
|
-
control: { type: "text" },
|
|
67
|
-
description: "Body text inside the default CTA glass panel — ignored if the cta-panel slot is used",
|
|
68
|
-
},
|
|
69
32
|
styleClassPassthrough: {
|
|
70
33
|
control: "object",
|
|
71
34
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -74,25 +37,22 @@ const meta: Meta<typeof ServicesSection> = {
|
|
|
74
37
|
args: {
|
|
75
38
|
tag: "section",
|
|
76
39
|
headerTag: "h2",
|
|
77
|
-
|
|
78
|
-
summaryAlignment: "center",
|
|
40
|
+
alignment: "center",
|
|
79
41
|
reverse: false,
|
|
80
|
-
durationIcon: "mdi:clock-time-four-outline",
|
|
81
|
-
priceIcon: "mdi:currency-gbp",
|
|
82
42
|
styleClassPassthrough: [],
|
|
83
43
|
},
|
|
84
44
|
parameters: {
|
|
85
45
|
docs: {
|
|
86
46
|
description: {
|
|
87
47
|
component:
|
|
88
|
-
"Renders a single service as a two-column
|
|
48
|
+
"Renders a single service as a compact two-column preview: image on one side, eyebrow/title/price-duration pills/summary text on the other, with a summary-link slot for navigating to the full ServiceDetail page. Superseded ServicesSection's old full mode — a ServiceDetail page is the correct place for process/ideal-for/FAQ/CTA content now.",
|
|
89
49
|
},
|
|
90
50
|
},
|
|
91
51
|
},
|
|
92
52
|
};
|
|
93
53
|
|
|
94
54
|
export default meta;
|
|
95
|
-
type Story = StoryObj<typeof
|
|
55
|
+
type Story = StoryObj<typeof ServiceSummary>;
|
|
96
56
|
|
|
97
57
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
98
58
|
|
|
@@ -153,136 +113,138 @@ const sampleService: Service = {
|
|
|
153
113
|
|
|
154
114
|
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
155
115
|
|
|
156
|
-
export const
|
|
157
|
-
name: "
|
|
158
|
-
args: {
|
|
159
|
-
ctaHeading: "Ready to book your appointment?",
|
|
160
|
-
ctaBody: "Mobile service across Bath — I come to you.",
|
|
161
|
-
},
|
|
116
|
+
export const Default: Story = {
|
|
117
|
+
name: "Default",
|
|
162
118
|
render: (args) => ({
|
|
163
|
-
components: {
|
|
119
|
+
components: { ServiceSummary },
|
|
164
120
|
setup() {
|
|
165
121
|
return { args, sampleService };
|
|
166
122
|
},
|
|
167
123
|
template: `
|
|
168
|
-
<
|
|
169
|
-
<template #
|
|
170
|
-
<
|
|
124
|
+
<ServiceSummary v-bind="args" :service-data="sampleService">
|
|
125
|
+
<template #summary-link="{ serviceData }">
|
|
126
|
+
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
127
|
+
More about {{ serviceData.title }} →
|
|
128
|
+
</a>
|
|
171
129
|
</template>
|
|
172
|
-
</
|
|
130
|
+
</ServiceSummary>
|
|
173
131
|
`,
|
|
174
132
|
}),
|
|
175
133
|
parameters: {
|
|
176
134
|
docs: {
|
|
177
135
|
description: {
|
|
178
|
-
story:
|
|
179
|
-
"Full mode (isSummary: false) — renders all content including a six-step process, ideal-for list, FAQs, and the CTA panel, matching the real service detail page content shape.",
|
|
136
|
+
story: "Eyebrow, title, price/duration pills, whatIsIt summary, and the summary-link slot.",
|
|
180
137
|
},
|
|
181
138
|
},
|
|
182
139
|
},
|
|
183
140
|
};
|
|
184
141
|
|
|
185
|
-
export const
|
|
186
|
-
name: "
|
|
142
|
+
export const Reversed: Story = {
|
|
143
|
+
name: "Reversed Layout",
|
|
187
144
|
args: {
|
|
188
|
-
|
|
145
|
+
reverse: true,
|
|
189
146
|
},
|
|
190
147
|
render: (args) => ({
|
|
191
|
-
components: {
|
|
148
|
+
components: { ServiceSummary },
|
|
192
149
|
setup() {
|
|
193
150
|
return { args, sampleService };
|
|
194
151
|
},
|
|
195
|
-
template:
|
|
196
|
-
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
197
|
-
<template #summary-link="{ serviceData }">
|
|
198
|
-
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
199
|
-
More about {{ serviceData.title }} →
|
|
200
|
-
</a>
|
|
201
|
-
</template>
|
|
202
|
-
</ServicesSection>
|
|
203
|
-
`,
|
|
152
|
+
template: `<ServiceSummary v-bind="args" :service-data="sampleService" />`,
|
|
204
153
|
}),
|
|
205
154
|
parameters: {
|
|
206
155
|
docs: {
|
|
207
156
|
description: {
|
|
208
|
-
story: "
|
|
157
|
+
story: "Image on the right, content on the left — use on alternating items in a list.",
|
|
209
158
|
},
|
|
210
159
|
},
|
|
211
160
|
},
|
|
212
161
|
};
|
|
213
162
|
|
|
214
|
-
export const
|
|
215
|
-
name: "
|
|
216
|
-
args: {
|
|
217
|
-
reverse: true,
|
|
218
|
-
},
|
|
163
|
+
export const FixedRowHeight: Story = {
|
|
164
|
+
name: "Fixed Row Height",
|
|
219
165
|
render: (args) => ({
|
|
220
|
-
components: {
|
|
166
|
+
components: { ServiceSummary },
|
|
221
167
|
setup() {
|
|
222
168
|
return { args, sampleService };
|
|
223
169
|
},
|
|
224
|
-
template:
|
|
170
|
+
template: `
|
|
171
|
+
<div style="--service-summary-height-mobile: 24rem; --service-summary-height-tablet: 28rem; --service-summary-height-desktop: 32rem;">
|
|
172
|
+
<ServiceSummary v-bind="args" :service-data="sampleService">
|
|
173
|
+
<template #summary-link="{ serviceData }">
|
|
174
|
+
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
175
|
+
More about {{ serviceData.title }} →
|
|
176
|
+
</a>
|
|
177
|
+
</template>
|
|
178
|
+
</ServiceSummary>
|
|
179
|
+
</div>
|
|
180
|
+
`,
|
|
225
181
|
}),
|
|
226
182
|
parameters: {
|
|
227
183
|
docs: {
|
|
228
184
|
description: {
|
|
229
|
-
story:
|
|
185
|
+
story:
|
|
186
|
+
"--service-summary-height-mobile/-tablet/-desktop set a fixed height for the whole row (both the image and the text column) per @container breakpoint, instead of the default auto — the image crops via object-fit: cover to fill it (--service-summary-image-aspect-ratio no longer has any visible effect once this is set), and the text column gets real leftover space for the alignment prop to position within. Useful for keeping every row in a list a consistent height regardless of each service's source photo dimensions.",
|
|
230
187
|
},
|
|
231
188
|
},
|
|
232
189
|
},
|
|
233
190
|
};
|
|
234
191
|
|
|
235
|
-
export const
|
|
236
|
-
name: "Custom
|
|
192
|
+
export const CustomAspectRatio: Story = {
|
|
193
|
+
name: "Custom Aspect Ratio",
|
|
237
194
|
args: {
|
|
238
|
-
|
|
239
|
-
priceIcon: "mdi:currency-eur",
|
|
195
|
+
alignment: "start",
|
|
240
196
|
},
|
|
241
197
|
render: (args) => ({
|
|
242
|
-
components: {
|
|
198
|
+
components: { ServiceSummary },
|
|
243
199
|
setup() {
|
|
244
200
|
return { args, sampleService };
|
|
245
201
|
},
|
|
246
|
-
template:
|
|
202
|
+
template: `
|
|
203
|
+
<div style="--service-summary-image-aspect-ratio: 3 / 4;">
|
|
204
|
+
<ServiceSummary v-bind="args" :service-data="sampleService">
|
|
205
|
+
<template #summary-link="{ serviceData }">
|
|
206
|
+
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
207
|
+
More about {{ serviceData.title }} →
|
|
208
|
+
</a>
|
|
209
|
+
</template>
|
|
210
|
+
</ServiceSummary>
|
|
211
|
+
</div>
|
|
212
|
+
`,
|
|
247
213
|
}),
|
|
248
214
|
parameters: {
|
|
249
215
|
docs: {
|
|
250
216
|
description: {
|
|
251
217
|
story:
|
|
252
|
-
"
|
|
218
|
+
"--service-summary-image-aspect-ratio (default 1 / 1, square) controls the image's own shape when the row height is left auto (the default) — here overridden to a taller 3 / 4 portrait crop. This token only has a visible effect while no --service-summary-height-* override forces a definite row height.",
|
|
253
219
|
},
|
|
254
220
|
},
|
|
255
221
|
},
|
|
256
222
|
};
|
|
257
223
|
|
|
258
|
-
export const
|
|
259
|
-
name: "
|
|
260
|
-
args: {
|
|
261
|
-
processHeading: "How It Works",
|
|
262
|
-
idealForHeading: "Who Is This For",
|
|
263
|
-
maintenanceHeading: "Aftercare",
|
|
264
|
-
faqsHeading: "FAQs",
|
|
265
|
-
ctaHeading: "Ready to book your colour appointment?",
|
|
266
|
-
ctaBody: "Mobile service across Bath — I come to you.",
|
|
267
|
-
},
|
|
224
|
+
export const FramedImage: Story = {
|
|
225
|
+
name: "Framed Image (padding)",
|
|
268
226
|
render: (args) => ({
|
|
269
|
-
components: {
|
|
227
|
+
components: { ServiceSummary },
|
|
270
228
|
setup() {
|
|
271
229
|
return { args, sampleService };
|
|
272
230
|
},
|
|
273
231
|
template: `
|
|
274
|
-
<
|
|
275
|
-
<
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
232
|
+
<div style="--service-summary-image-padding-block-mobile: 1.6rem; --service-summary-image-padding-inline-mobile: 1.6rem; background: #1a1a1a;">
|
|
233
|
+
<ServiceSummary v-bind="args" :service-data="sampleService">
|
|
234
|
+
<template #summary-link="{ serviceData }">
|
|
235
|
+
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
236
|
+
More about {{ serviceData.title }} →
|
|
237
|
+
</a>
|
|
238
|
+
</template>
|
|
239
|
+
</ServiceSummary>
|
|
240
|
+
</div>
|
|
279
241
|
`,
|
|
280
242
|
}),
|
|
281
243
|
parameters: {
|
|
282
244
|
docs: {
|
|
283
245
|
description: {
|
|
284
246
|
story:
|
|
285
|
-
"
|
|
247
|
+
"--service-summary-image-padding-block-*/-inline-* insets the image within .service-summary__image-wrapper instead of letting it fill edge-to-edge — the wrapper's own background shows through as a frame. Combine with a background colour set on the wrapper (via styleClassPassthrough) for a card-like look.",
|
|
286
248
|
},
|
|
287
249
|
},
|
|
288
250
|
},
|
|
@@ -290,12 +252,8 @@ export const CustomTextAndCta: Story = {
|
|
|
290
252
|
|
|
291
253
|
export const RealisticPageContext: Story = {
|
|
292
254
|
name: "Realistic Page Context",
|
|
293
|
-
args: {
|
|
294
|
-
ctaHeading: "Ready to book your appointment?",
|
|
295
|
-
ctaBody: "Mobile service across Bath — I come to you.",
|
|
296
|
-
},
|
|
297
255
|
render: (args) => ({
|
|
298
|
-
components: {
|
|
256
|
+
components: { ServiceSummary, PageRow, TextBlock, EyebrowText, HeroText },
|
|
299
257
|
setup() {
|
|
300
258
|
return { args, sampleService };
|
|
301
259
|
},
|
|
@@ -319,11 +277,13 @@ export const RealisticPageContext: Story = {
|
|
|
319
277
|
</PageRow>
|
|
320
278
|
|
|
321
279
|
<PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
|
|
322
|
-
<
|
|
323
|
-
<template #
|
|
324
|
-
<
|
|
280
|
+
<ServiceSummary v-bind="args" :service-data="sampleService">
|
|
281
|
+
<template #summary-link="{ serviceData }">
|
|
282
|
+
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
283
|
+
More about {{ serviceData.title }} →
|
|
284
|
+
</a>
|
|
325
285
|
</template>
|
|
326
|
-
</
|
|
286
|
+
</ServiceSummary>
|
|
327
287
|
</PageRow>
|
|
328
288
|
</div>
|
|
329
289
|
`,
|
|
@@ -332,37 +292,7 @@ export const RealisticPageContext: Story = {
|
|
|
332
292
|
docs: {
|
|
333
293
|
description: {
|
|
334
294
|
story:
|
|
335
|
-
"Matches the real
|
|
336
|
-
},
|
|
337
|
-
},
|
|
338
|
-
},
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
export const CustomCtaPanel: Story = {
|
|
342
|
-
name: "Custom CTA Panel (slot override)",
|
|
343
|
-
render: (args) => ({
|
|
344
|
-
components: { ServicesSection },
|
|
345
|
-
setup() {
|
|
346
|
-
return { args, sampleService };
|
|
347
|
-
},
|
|
348
|
-
template: `
|
|
349
|
-
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
350
|
-
<template #cta-panel="{ serviceData }">
|
|
351
|
-
<div style="padding:2.4rem;border:2px dashed #333;border-radius:1rem;text-align:center;">
|
|
352
|
-
<p style="margin:0 0 1.2rem;">Fully custom CTA block for {{ serviceData.title }} — not a GlassPanel at all.</p>
|
|
353
|
-
<button style="padding:1.2rem 2.4rem;background:#333;color:#fff;border:none;border-radius:0.4rem;cursor:pointer;">
|
|
354
|
-
Enquire Now
|
|
355
|
-
</button>
|
|
356
|
-
</div>
|
|
357
|
-
</template>
|
|
358
|
-
</ServicesSection>
|
|
359
|
-
`,
|
|
360
|
-
}),
|
|
361
|
-
parameters: {
|
|
362
|
-
docs: {
|
|
363
|
-
description: {
|
|
364
|
-
story:
|
|
365
|
-
"The cta-panel scoped slot replaces the default GlassPanel entirely — use when a consumer needs a different component or layout for the closing call-to-action, not just different copy.",
|
|
295
|
+
"Matches the real services listing page (app/pages/services/index.vue in the luxury-locs-by-natasha-nuxt3 project): a page hero (EyebrowText + HeroText inside PageRow/TextBlock) followed by a ServiceSummary. Use ServiceSummaryGrid for the full alternating listing — this story sanity-checks a single instance's layout in context.",
|
|
366
296
|
},
|
|
367
297
|
},
|
|
368
298
|
},
|