srcdev-nuxt-components 9.1.22 → 9.1.24
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/settings.json +10 -1
- package/.claude/skills/components/services-section-grid.md +130 -0
- package/.claude/skills/components/services-section.md +16 -0
- package/.claude/skills/index.md +3 -2
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +193 -0
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +202 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +12 -0
- package/app/components/03.organisms/services/services-grids/ServicesSectionGrid.vue +1 -17
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +181 -0
- package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +196 -0
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +180 -0
- package/app/components/03.organisms/services/services-grids/tests/ServicesSectionGrid.spec.ts +167 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +42 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +87 -0
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +6 -2
- package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +214 -0
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +213 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +84 -0
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -27,7 +27,16 @@
|
|
|
27
27
|
"Bash(git -C /Users/simoncornforth/websites/nuxt-components show 5597a15 -- \"*.vue\" \"*.css\")",
|
|
28
28
|
"Bash(npm install:*)",
|
|
29
29
|
"Bash(npm run:*)",
|
|
30
|
-
"Bash(git add:*)"
|
|
30
|
+
"Bash(git add:*)",
|
|
31
|
+
"Bash(ls /Users/simoncornforth/websites/nuxt-components/*.md)",
|
|
32
|
+
"Bash(gh release:*)",
|
|
33
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components status)",
|
|
34
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components add .claude/skills/components/services-section.md .claude/skills/components/services-section-grid.md .claude/skills/index.md app/components/03.organisms/services/services-grids/ServicesSectionGrid.vue app/components/03.organisms/services/services-section/ServicesSection.vue app/components/03.organisms/services/services-card/stories/ app/components/03.organisms/services/services-card/tests/ app/components/03.organisms/services/services-grids/stories/ app/components/03.organisms/services/services-grids/tests/ app/components/03.organisms/services/services-section/stories/ app/components/03.organisms/services/services-section/tests/)",
|
|
35
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components commit -m ':*)",
|
|
36
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline v9.1.22..HEAD)",
|
|
37
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline c401cea..HEAD)",
|
|
38
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components show 3058d63 --stat)",
|
|
39
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components show 0f99d7a --stat)"
|
|
31
40
|
],
|
|
32
41
|
"additionalDirectories": [
|
|
33
42
|
"/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# ServicesSectionGrid Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`ServicesSectionGrid` renders a vertical stack of `ServicesSection` components (in summary mode) from a `Service[]` array. Each section includes a hardcoded `summary-link` (navigates to the service detail page) and a `cta` button. Column order can be alternated on every other section via `useAlternateReverse`.
|
|
6
|
+
|
|
7
|
+
All routing and CTA decisions are delegated to the consumer via scoped slots — the grid forwards `serviceData` from each `ServicesSection` iteration so the consumer can build hrefs, labels, and choose components freely.
|
|
8
|
+
|
|
9
|
+
## Props
|
|
10
|
+
|
|
11
|
+
| Prop | Type | Default | Required |
|
|
12
|
+
| ----------------------- | ------------------------------ | ----------- | -------- |
|
|
13
|
+
| `servicesData` | `Service[]` | — | **yes** |
|
|
14
|
+
| `tag` | `"div" \| "section" \| "main"` | `"div"` | no |
|
|
15
|
+
| `useAlternateReverse` | `boolean` | `false` | no |
|
|
16
|
+
| `summaryAlignment` | `"start" \| "center" \| "end"` | `"center"` | no |
|
|
17
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
18
|
+
|
|
19
|
+
### `useAlternateReverse`
|
|
20
|
+
|
|
21
|
+
When `true`, odd-indexed sections flip their image/content column order (`reverse` prop on each `ServicesSection`), creating a visual zigzag layout. Commonly used on services landing pages with three or more services.
|
|
22
|
+
|
|
23
|
+
### `summaryAlignment`
|
|
24
|
+
|
|
25
|
+
Passed to every `ServicesSection` as `summaryAlignment` — controls vertical alignment of the info column content within the grid cell.
|
|
26
|
+
|
|
27
|
+
## Slots
|
|
28
|
+
|
|
29
|
+
| Slot | Slot props | Purpose |
|
|
30
|
+
| -------------- | -------------------------- | --------------------------------------------------------- |
|
|
31
|
+
| `summary-link` | `{ serviceData: Service }` | Navigation link below the `whatIsIt` text in each section |
|
|
32
|
+
|
|
33
|
+
The slot is forwarded from the inner `ServicesSection` and receives the current iteration's `serviceData` as a scoped prop. It is optional — omitting it leaves that area empty.
|
|
34
|
+
|
|
35
|
+
> **Note**: `ServicesSectionGrid` always renders sections in summary mode (`isSummary: true`), so only `summary-link` is available. The `cta` slot (which lives inside a full-mode `GlassPanel`) is not reachable from this component.
|
|
36
|
+
|
|
37
|
+
## Basic usage
|
|
38
|
+
|
|
39
|
+
```vue
|
|
40
|
+
<ServicesSectionGrid
|
|
41
|
+
:services-data="servicesData ?? []"
|
|
42
|
+
:use-alternate-reverse="true"
|
|
43
|
+
tag="section"
|
|
44
|
+
>
|
|
45
|
+
<template #summary-link="{ serviceData }">
|
|
46
|
+
<LinkText
|
|
47
|
+
:to="`/services/${serviceData.slug}`"
|
|
48
|
+
:link-text="`More about ${serviceData.title}`"
|
|
49
|
+
:style-class-passthrough="['mb-20']"
|
|
50
|
+
/>
|
|
51
|
+
</template>
|
|
52
|
+
</ServicesSectionGrid>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Alternate reverse (zigzag) layout
|
|
56
|
+
|
|
57
|
+
```vue
|
|
58
|
+
<ServicesSectionGrid
|
|
59
|
+
:services-data="servicesData ?? []"
|
|
60
|
+
:use-alternate-reverse="true"
|
|
61
|
+
tag="section"
|
|
62
|
+
>
|
|
63
|
+
<!-- slots as above -->
|
|
64
|
+
</ServicesSectionGrid>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Consumer page boilerplate
|
|
68
|
+
|
|
69
|
+
```vue
|
|
70
|
+
<template>
|
|
71
|
+
<div>
|
|
72
|
+
<NuxtLayout name="default">
|
|
73
|
+
<template #layout-content>
|
|
74
|
+
<LayoutRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
|
|
75
|
+
<h1 class="page-heading-1">Our Services</h1>
|
|
76
|
+
</LayoutRow>
|
|
77
|
+
|
|
78
|
+
<LayoutRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
|
|
79
|
+
<ServicesSectionGrid
|
|
80
|
+
:services-data="servicesData ?? []"
|
|
81
|
+
:use-alternate-reverse="true"
|
|
82
|
+
tag="section"
|
|
83
|
+
>
|
|
84
|
+
<template #summary-link="{ serviceData }">
|
|
85
|
+
<LinkText
|
|
86
|
+
:to="`/services/${serviceData.slug}`"
|
|
87
|
+
:link-text="`More about ${serviceData.title}`"
|
|
88
|
+
:style-class-passthrough="['mb-20']"
|
|
89
|
+
/>
|
|
90
|
+
</template>
|
|
91
|
+
</ServicesSectionGrid>
|
|
92
|
+
</LayoutRow>
|
|
93
|
+
</template>
|
|
94
|
+
</NuxtLayout>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup lang="ts">
|
|
99
|
+
definePageMeta({ layout: false });
|
|
100
|
+
|
|
101
|
+
useHead({
|
|
102
|
+
title: "Services",
|
|
103
|
+
meta: [{ name: "description", content: "Browse our services" }],
|
|
104
|
+
bodyAttrs: { class: "page-services" },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const store = useServicesStore();
|
|
108
|
+
const { servicesData } = storeToRefs(store);
|
|
109
|
+
|
|
110
|
+
if (servicesData.value.length === 0) {
|
|
111
|
+
await store.fetchServicesData();
|
|
112
|
+
}
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<style lang="css">
|
|
116
|
+
.page-services {
|
|
117
|
+
.services-grid {
|
|
118
|
+
gap: 6rem;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Notes
|
|
125
|
+
|
|
126
|
+
- Component is auto-imported in Nuxt — no import needed.
|
|
127
|
+
- The `Service` type is imported from `~/types/types.services`.
|
|
128
|
+
- Each `ServicesSection` receives its array `index` for eager/lazy image loading decisions (first two sections load eagerly).
|
|
129
|
+
- Pass `servicesData ?? []` as a safe fallback while data loads asynchronously.
|
|
130
|
+
- See [services-section.md](./services-section.md) for the full `ServicesSection` prop and slot API.
|
|
@@ -19,8 +19,24 @@ All routing and CTA decisions are delegated to the consumer via slots.
|
|
|
19
19
|
| `isSummary` | `boolean` | `false` | no |
|
|
20
20
|
| `summaryAlignment` | `"start" \| "center" \| "end"` | `"center"` | no |
|
|
21
21
|
| `reverse` | `boolean` | `false` | no |
|
|
22
|
+
| `durationIcon` | `string` | `"mdi:clock-time-four-outline"` | no |
|
|
23
|
+
| `priceIcon` | `string` | `"mdi:currency-gbp"` | no |
|
|
22
24
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
23
25
|
|
|
26
|
+
### Icon customisation
|
|
27
|
+
|
|
28
|
+
The two icons in the price/duration row default to `mdi:clock-time-four-outline` and `mdi:currency-gbp`. Override them for a different currency or icon style:
|
|
29
|
+
|
|
30
|
+
```vue
|
|
31
|
+
<ServicesSection
|
|
32
|
+
:service-data="service"
|
|
33
|
+
duration-icon="mdi:timer-outline"
|
|
34
|
+
price-icon="mdi:currency-eur"
|
|
35
|
+
/>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Any [Iconify](https://icon-sets.iconify.design/) icon name is accepted. See `.claude/skills/icon-sets.md` for the icon packages available in this layer.
|
|
39
|
+
|
|
24
40
|
### `index` and image loading
|
|
25
41
|
|
|
26
42
|
The `index` prop controls eager vs lazy image loading. The first two sections (`index` 0 and 1) load eagerly; all others load lazily. Pass the loop index when rendering a list of sections.
|
package/.claude/skills/index.md
CHANGED
|
@@ -51,8 +51,9 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
51
51
|
├── link-text.md — LinkText props, slots, usage patterns, styling
|
|
52
52
|
├── page-hero-highlights.md — PageHeroHighlights template: hero + highlights strip grid, CSS custom property theming
|
|
53
53
|
├── services-card.md — ServicesCard props (incl. eyebrowConfig/heroConfig), actions slot, CSS tokens, page boilerplate
|
|
54
|
-
├── services-card-grid.md
|
|
55
|
-
├── services-section.md —
|
|
54
|
+
├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
|
|
55
|
+
├── services-section-grid.md — ServicesSectionGrid props, useAlternateReverse zigzag layout, page boilerplate
|
|
56
|
+
├── services-section.md — ServicesSection props, summary-link/cta slots, summary vs full mode
|
|
56
57
|
├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
|
|
57
58
|
├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
|
|
58
59
|
├── expanding-panel.md — ExpandingPanel v-model, forceOpened, slots (summary/icon/content), ARIA wiring
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
13
|
+
.entry-slide-in {
|
|
14
|
+
animation: entrySlideInAnimation both;
|
|
15
|
+
animation-timeline: view(70% 5%);
|
|
16
|
+
}
|
|
15
17
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import ServicesCard from "../ServicesCard.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import type { Service } from "~/types/types.services";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ServicesCard> = {
|
|
6
|
+
title: "Organisms/Services/Services Card",
|
|
7
|
+
component: ServicesCard,
|
|
8
|
+
argTypes: {
|
|
9
|
+
tag: {
|
|
10
|
+
control: { type: "select" },
|
|
11
|
+
options: ["div", "section", "article"],
|
|
12
|
+
description: "HTML element rendered as the root",
|
|
13
|
+
},
|
|
14
|
+
eyebrowConfig: {
|
|
15
|
+
control: "object",
|
|
16
|
+
description: "Override eyebrow tag and fontSize — omit keys to use defaults",
|
|
17
|
+
},
|
|
18
|
+
heroConfig: {
|
|
19
|
+
control: "object",
|
|
20
|
+
description: "Override hero tag and fontSize — omit keys to use defaults",
|
|
21
|
+
},
|
|
22
|
+
styleClassPassthrough: {
|
|
23
|
+
control: "object",
|
|
24
|
+
description: "Additional CSS classes applied to the root element",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
args: {
|
|
28
|
+
tag: "div",
|
|
29
|
+
eyebrowConfig: {},
|
|
30
|
+
heroConfig: {},
|
|
31
|
+
styleClassPassthrough: [],
|
|
32
|
+
},
|
|
33
|
+
parameters: {
|
|
34
|
+
docs: {
|
|
35
|
+
description: {
|
|
36
|
+
component:
|
|
37
|
+
"Portrait card for a single service: image, subtitle (eyebrow), title, short description, and an `actions` slot for CTA content. Heading levels and eyebrow sizing are configurable via `eyebrowConfig` and `heroConfig`. Usually consumed via `ServicesCardGrid`.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default meta;
|
|
44
|
+
type Story = StoryObj<typeof ServicesCard>;
|
|
45
|
+
|
|
46
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const sampleService: Service = {
|
|
49
|
+
slug: "locs-installation",
|
|
50
|
+
title: "Locs Installation",
|
|
51
|
+
subtitle: "Start your loc journey",
|
|
52
|
+
price: "£120",
|
|
53
|
+
duration: "3–4 hours",
|
|
54
|
+
image: "https://picsum.photos/seed/locs-card/600/800",
|
|
55
|
+
shortDescription:
|
|
56
|
+
"Professional loc installation tailored to your hair type. We work with all textures and lengths to create beautiful, long-lasting locs.",
|
|
57
|
+
longDescription: "Full description not used in card view.",
|
|
58
|
+
heroHeading: [{ text: "Why choose locs?", styleClass: "normal" }],
|
|
59
|
+
whatIsIt: "Locs are a protective hairstyle.",
|
|
60
|
+
process: ["Consultation", "Cleanse", "Palm-roll", "Dry & finish"],
|
|
61
|
+
idealFor: ["Natural hair", "Low maintenance seekers"],
|
|
62
|
+
maintenance: "Re-twist every 4–6 weeks.",
|
|
63
|
+
faqs: [{ question: "How long do locs take to mature?", answer: "12–18 months." }],
|
|
64
|
+
seoTitle: "Locs Installation",
|
|
65
|
+
seoDescription: "Professional locs installation service.",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export const WithActions: Story = {
|
|
71
|
+
name: "With Actions Slot",
|
|
72
|
+
render: (args) => ({
|
|
73
|
+
components: { ServicesCard },
|
|
74
|
+
setup() {
|
|
75
|
+
return { args, sampleService };
|
|
76
|
+
},
|
|
77
|
+
template: `
|
|
78
|
+
<div style="max-width:320px">
|
|
79
|
+
<ServicesCard v-bind="args" :service-data="sampleService">
|
|
80
|
+
<template #actions="{ serviceData }">
|
|
81
|
+
<a
|
|
82
|
+
:href="'/services/' + serviceData.slug"
|
|
83
|
+
style="display:inline-flex;align-items:center;gap:0.6rem;margin-top:1.6rem;padding:1.2rem 2.4rem;background:#333;color:#fff;border-radius:0.4rem;text-decoration:none;font-size:1.4rem;"
|
|
84
|
+
>
|
|
85
|
+
More about {{ serviceData.title }}
|
|
86
|
+
</a>
|
|
87
|
+
</template>
|
|
88
|
+
</ServicesCard>
|
|
89
|
+
</div>
|
|
90
|
+
`,
|
|
91
|
+
}),
|
|
92
|
+
parameters: {
|
|
93
|
+
docs: {
|
|
94
|
+
description: {
|
|
95
|
+
story: "Standard usage — actions slot receives serviceData as a scoped prop for constructing hrefs and labels.",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const WithoutActions: Story = {
|
|
102
|
+
name: "Without Actions Slot",
|
|
103
|
+
render: (args) => ({
|
|
104
|
+
components: { ServicesCard },
|
|
105
|
+
setup() {
|
|
106
|
+
return { args, sampleService };
|
|
107
|
+
},
|
|
108
|
+
template: `
|
|
109
|
+
<div style="max-width:320px">
|
|
110
|
+
<ServicesCard v-bind="args" :service-data="sampleService" />
|
|
111
|
+
</div>
|
|
112
|
+
`,
|
|
113
|
+
}),
|
|
114
|
+
parameters: {
|
|
115
|
+
docs: {
|
|
116
|
+
description: {
|
|
117
|
+
story: "The actions slot is optional — card renders cleanly without it.",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const ConfigOverrides: Story = {
|
|
124
|
+
name: "Config Overrides",
|
|
125
|
+
args: {
|
|
126
|
+
eyebrowConfig: { tag: "p", fontSize: "small" },
|
|
127
|
+
heroConfig: { tag: "h3", fontSize: "title" },
|
|
128
|
+
},
|
|
129
|
+
render: (args) => ({
|
|
130
|
+
components: { ServicesCard },
|
|
131
|
+
setup() {
|
|
132
|
+
return { args, sampleService };
|
|
133
|
+
},
|
|
134
|
+
template: `
|
|
135
|
+
<div style="max-width:320px">
|
|
136
|
+
<ServicesCard v-bind="args" :service-data="sampleService">
|
|
137
|
+
<template #actions="{ serviceData }">
|
|
138
|
+
<a
|
|
139
|
+
:href="'/services/' + serviceData.slug"
|
|
140
|
+
style="display:inline-flex;margin-top:1.6rem;padding:1.2rem 2.4rem;background:#333;color:#fff;border-radius:0.4rem;text-decoration:none;font-size:1.4rem;"
|
|
141
|
+
>
|
|
142
|
+
Enquire
|
|
143
|
+
</a>
|
|
144
|
+
</template>
|
|
145
|
+
</ServicesCard>
|
|
146
|
+
</div>
|
|
147
|
+
`,
|
|
148
|
+
}),
|
|
149
|
+
parameters: {
|
|
150
|
+
docs: {
|
|
151
|
+
description: {
|
|
152
|
+
story:
|
|
153
|
+
"eyebrowConfig and heroConfig let consumers adjust heading levels and text sizing without touching the component — useful when multiple cards are nested inside a page with its own heading hierarchy.",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const AsArticle: Story = {
|
|
160
|
+
name: "As Article Element",
|
|
161
|
+
args: {
|
|
162
|
+
tag: "article",
|
|
163
|
+
heroConfig: { tag: "h3" },
|
|
164
|
+
},
|
|
165
|
+
render: (args) => ({
|
|
166
|
+
components: { ServicesCard },
|
|
167
|
+
setup() {
|
|
168
|
+
return { args, sampleService };
|
|
169
|
+
},
|
|
170
|
+
template: `
|
|
171
|
+
<div style="max-width:320px">
|
|
172
|
+
<ServicesCard v-bind="args" :service-data="sampleService">
|
|
173
|
+
<template #actions="{ serviceData }">
|
|
174
|
+
<a
|
|
175
|
+
:href="'/services/' + serviceData.slug"
|
|
176
|
+
style="display:inline-flex;margin-top:1.6rem;padding:1.2rem 2.4rem;background:#333;color:#fff;border-radius:0.4rem;text-decoration:none;font-size:1.4rem;"
|
|
177
|
+
>
|
|
178
|
+
View service
|
|
179
|
+
</a>
|
|
180
|
+
</template>
|
|
181
|
+
</ServicesCard>
|
|
182
|
+
</div>
|
|
183
|
+
`,
|
|
184
|
+
}),
|
|
185
|
+
parameters: {
|
|
186
|
+
docs: {
|
|
187
|
+
description: {
|
|
188
|
+
story:
|
|
189
|
+
"Use tag=\"article\" with heroConfig.tag=\"h3\" when cards are inside a section that already has an h2 — keeps the heading hierarchy correct.",
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import ServicesCard from "../ServicesCard.vue";
|
|
4
|
+
import type { Service } from "~/types/types.services";
|
|
5
|
+
|
|
6
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const mockService: Service = {
|
|
9
|
+
slug: "test-service",
|
|
10
|
+
title: "Test Service",
|
|
11
|
+
subtitle: "A subtitle",
|
|
12
|
+
price: "£50",
|
|
13
|
+
duration: "60 mins",
|
|
14
|
+
image: "/images/test.jpg",
|
|
15
|
+
shortDescription: "A short description of the service.",
|
|
16
|
+
longDescription: "Long description text",
|
|
17
|
+
heroHeading: [{ text: "A great heading", styleClass: "normal" }],
|
|
18
|
+
whatIsIt: "What this service is",
|
|
19
|
+
process: ["Step one", "Step two"],
|
|
20
|
+
idealFor: ["Person A", "Person B"],
|
|
21
|
+
maintenance: "Maintenance advice",
|
|
22
|
+
faqs: [{ question: "FAQ question?", answer: "FAQ answer." }],
|
|
23
|
+
seoTitle: "SEO Title",
|
|
24
|
+
seoDescription: "SEO description",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("ServicesCard", () => {
|
|
30
|
+
// ─── Mount ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
it("mounts without error", async () => {
|
|
33
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
34
|
+
props: { serviceData: mockService },
|
|
35
|
+
});
|
|
36
|
+
expect(wrapper.vm).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders correct HTML structure", async () => {
|
|
40
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
41
|
+
props: { serviceData: mockService },
|
|
42
|
+
});
|
|
43
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ─── Root element ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
it("has services-card class on root", async () => {
|
|
49
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
50
|
+
props: { serviceData: mockService },
|
|
51
|
+
});
|
|
52
|
+
expect(wrapper.classes()).toContain("services-card");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders as a div by default", async () => {
|
|
56
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
57
|
+
props: { serviceData: mockService },
|
|
58
|
+
});
|
|
59
|
+
expect(wrapper.element.tagName).toBe("DIV");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders the tag prop as the root element", async () => {
|
|
63
|
+
for (const tag of ["section", "article"] as const) {
|
|
64
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
65
|
+
props: { serviceData: mockService, tag },
|
|
66
|
+
});
|
|
67
|
+
expect(wrapper.element.tagName).toBe(tag.toUpperCase());
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ─── Service data ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
it("renders the service title", async () => {
|
|
74
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
75
|
+
props: { serviceData: mockService },
|
|
76
|
+
});
|
|
77
|
+
expect(wrapper.text()).toContain(mockService.title);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders the service subtitle via eyebrow", async () => {
|
|
81
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
82
|
+
props: { serviceData: mockService },
|
|
83
|
+
});
|
|
84
|
+
expect(wrapper.text()).toContain(mockService.subtitle);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders the short description", async () => {
|
|
88
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
89
|
+
props: { serviceData: mockService },
|
|
90
|
+
});
|
|
91
|
+
expect(wrapper.find(".description").text()).toBe(mockService.shortDescription);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("passes image src and alt to the image element", async () => {
|
|
95
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
96
|
+
props: { serviceData: mockService },
|
|
97
|
+
});
|
|
98
|
+
const img = wrapper.find(".image-wrapper img");
|
|
99
|
+
expect(img.attributes("src")).toContain(mockService.image);
|
|
100
|
+
expect(img.attributes("alt")).toBe(mockService.title);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("loads the image lazily", async () => {
|
|
104
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
105
|
+
props: { serviceData: mockService },
|
|
106
|
+
});
|
|
107
|
+
expect(wrapper.find(".image-wrapper img").attributes("loading")).toBe("lazy");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─── Actions slot ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
it("renders the actions slot", async () => {
|
|
113
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
114
|
+
props: { serviceData: mockService },
|
|
115
|
+
slots: {
|
|
116
|
+
actions: '<button class="test-cta">Book now</button>',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
expect(wrapper.find(".test-cta").exists()).toBe(true);
|
|
120
|
+
expect(wrapper.find(".test-cta").text()).toBe("Book now");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("renders without error when no actions slot is provided", async () => {
|
|
124
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
125
|
+
props: { serviceData: mockService },
|
|
126
|
+
});
|
|
127
|
+
expect(wrapper.vm).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ─── eyebrowConfig ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
it("uses default eyebrow fontSize of large", async () => {
|
|
133
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
134
|
+
props: { serviceData: mockService },
|
|
135
|
+
});
|
|
136
|
+
expect(wrapper.find(".eyebrow-text").classes()).toContain("large");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("applies eyebrowConfig.fontSize override", async () => {
|
|
140
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
141
|
+
props: { serviceData: mockService, eyebrowConfig: { fontSize: "small" } },
|
|
142
|
+
});
|
|
143
|
+
expect(wrapper.find(".eyebrow-text").classes()).toContain("small");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("applies eyebrowConfig.tag override", async () => {
|
|
147
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
148
|
+
props: { serviceData: mockService, eyebrowConfig: { tag: "p" } },
|
|
149
|
+
});
|
|
150
|
+
expect(wrapper.find(".eyebrow-text").element.tagName).toBe("P");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── heroConfig ─────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it("uses default hero tag of h2", async () => {
|
|
156
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
157
|
+
props: { serviceData: mockService },
|
|
158
|
+
});
|
|
159
|
+
expect(wrapper.find(".hero-text").element.tagName).toBe("H2");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("applies heroConfig.tag override", async () => {
|
|
163
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
164
|
+
props: { serviceData: mockService, heroConfig: { tag: "h3" } },
|
|
165
|
+
});
|
|
166
|
+
expect(wrapper.find(".hero-text").element.tagName).toBe("H3");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("applies heroConfig.fontSize override", async () => {
|
|
170
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
171
|
+
props: { serviceData: mockService, heroConfig: { fontSize: "title" } },
|
|
172
|
+
});
|
|
173
|
+
expect(wrapper.find(".hero-text").classes()).toContain("title");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ─── styleClassPassthrough ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
it("applies a single styleClassPassthrough string", async () => {
|
|
179
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
180
|
+
props: { serviceData: mockService, styleClassPassthrough: "custom-card" },
|
|
181
|
+
});
|
|
182
|
+
expect(wrapper.classes()).toContain("custom-card");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
186
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
187
|
+
props: { serviceData: mockService, styleClassPassthrough: ["class-a", "class-b"] },
|
|
188
|
+
});
|
|
189
|
+
expect(wrapper.classes()).toContain("class-a");
|
|
190
|
+
expect(wrapper.classes()).toContain("class-b");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
194
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
195
|
+
props: { serviceData: mockService, styleClassPassthrough: ["original"] },
|
|
196
|
+
});
|
|
197
|
+
expect(wrapper.classes()).toContain("original");
|
|
198
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
199
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
200
|
+
expect(wrapper.classes()).toContain("updated");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ServicesCard > renders correct HTML structure 1`] = `
|
|
4
|
+
"<div class="services-card">
|
|
5
|
+
<div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="lazy" class="image" src="/_ipx/_/images/test.jpg"></div>
|
|
6
|
+
<div class="eyebrow-text large">A subtitle</div>
|
|
7
|
+
<h2 class="hero-text heading axis-horizontal">
|
|
8
|
+
<!--v-if--><span class="text-block-0 normal">Test Service</span>
|
|
9
|
+
</h2>
|
|
10
|
+
<div class="description">A short description of the service.</div>
|
|
11
|
+
</div>"
|
|
12
|
+
`;
|
|
@@ -10,23 +10,7 @@
|
|
|
10
10
|
:summary-alignment="summaryAlignment"
|
|
11
11
|
>
|
|
12
12
|
<template #summary-link="{ serviceData }">
|
|
13
|
-
<
|
|
14
|
-
:to="`/ui/services/services-section/${serviceData.slug}`"
|
|
15
|
-
:link-text="`More about ${serviceData.title}`"
|
|
16
|
-
:style-class-passthrough="['mb-20']"
|
|
17
|
-
/>
|
|
18
|
-
</template>
|
|
19
|
-
<template #cta="{ serviceData }">
|
|
20
|
-
<InputButtonCore
|
|
21
|
-
variant="secondary"
|
|
22
|
-
:button-text="`Enquire about ${serviceData.title}`"
|
|
23
|
-
href="#"
|
|
24
|
-
:style-class-passthrough="['mbs-24']"
|
|
25
|
-
>
|
|
26
|
-
<template #right>
|
|
27
|
-
<Icon name="mdi:arrow-right" class="icon" />
|
|
28
|
-
</template>
|
|
29
|
-
</InputButtonCore>
|
|
13
|
+
<slot name="summary-link" :service-data="serviceData"></slot>
|
|
30
14
|
</template>
|
|
31
15
|
</ServicesSection>
|
|
32
16
|
</component>
|