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/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import ServicesCardGrid from "../ServicesCardGrid.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import type { Service } from "~/types/types.services";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ServicesCardGrid> = {
|
|
6
|
+
title: "Organisms/Services/Services Card Grid",
|
|
7
|
+
component: ServicesCardGrid,
|
|
8
|
+
argTypes: {
|
|
9
|
+
tag: {
|
|
10
|
+
control: { type: "select" },
|
|
11
|
+
options: ["div", "section", "main"],
|
|
12
|
+
description: "HTML element rendered as the root",
|
|
13
|
+
},
|
|
14
|
+
eyebrowConfig: {
|
|
15
|
+
control: "object",
|
|
16
|
+
description: "Override eyebrow tag and fontSize on every card — omit keys to use defaults",
|
|
17
|
+
},
|
|
18
|
+
heroConfig: {
|
|
19
|
+
control: "object",
|
|
20
|
+
description: "Override hero tag and fontSize on every card — omit keys to use defaults",
|
|
21
|
+
},
|
|
22
|
+
hrefBase: {
|
|
23
|
+
control: { type: "text" },
|
|
24
|
+
description: "Base path for each card's CTA button — appended with the service slug",
|
|
25
|
+
},
|
|
26
|
+
buttonTextPrefix: {
|
|
27
|
+
control: { type: "text" },
|
|
28
|
+
description: "Prefix for each card's CTA button text — appended with the service title",
|
|
29
|
+
},
|
|
30
|
+
styleClassPassthrough: {
|
|
31
|
+
control: "object",
|
|
32
|
+
description: "Additional CSS classes applied to the root element",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
args: {
|
|
36
|
+
tag: "div",
|
|
37
|
+
eyebrowConfig: {},
|
|
38
|
+
heroConfig: {},
|
|
39
|
+
hrefBase: "/services/",
|
|
40
|
+
buttonTextPrefix: "Enquire about",
|
|
41
|
+
styleClassPassthrough: [],
|
|
42
|
+
},
|
|
43
|
+
parameters: {
|
|
44
|
+
docs: {
|
|
45
|
+
description: {
|
|
46
|
+
component:
|
|
47
|
+
"Responsive auto-fit grid of `ServicesCard` components from a `Service[]` array. Button hrefs and text are built from `hrefBase` + `service.slug` and `buttonTextPrefix` + `service.title`. Typography is configured once via `eyebrowConfig` and `heroConfig` and applied to every card.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default meta;
|
|
54
|
+
type Story = StoryObj<typeof ServicesCardGrid>;
|
|
55
|
+
|
|
56
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const makeService = (slug: string, title: string, subtitle: string, image: string): Service => ({
|
|
59
|
+
slug,
|
|
60
|
+
title,
|
|
61
|
+
subtitle,
|
|
62
|
+
price: "£120",
|
|
63
|
+
duration: "3–4 hours",
|
|
64
|
+
image,
|
|
65
|
+
shortDescription: `Professional ${title.toLowerCase()} service tailored to your hair type and style goals.`,
|
|
66
|
+
longDescription: "Full description available on the service detail page.",
|
|
67
|
+
heroHeading: [{ text: `Why choose ${title}?`, styleClass: "normal" }],
|
|
68
|
+
whatIsIt: `${title} is a popular protective style.`,
|
|
69
|
+
process: ["Consultation", "Preparation", "Application", "Finishing"],
|
|
70
|
+
idealFor: ["Natural hair", "All textures"],
|
|
71
|
+
maintenance: "Regular maintenance recommended every 4–6 weeks.",
|
|
72
|
+
faqs: [{ question: "How long does it last?", answer: "Results vary by hair type." }],
|
|
73
|
+
seoTitle: title,
|
|
74
|
+
seoDescription: `Book a ${title.toLowerCase()} appointment today.`,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const sampleServices: Service[] = [
|
|
78
|
+
makeService("locs-installation", "Locs Installation", "Start your loc journey", "https://picsum.photos/seed/card-a/600/800"),
|
|
79
|
+
makeService("locs-retwist", "Locs Retwist", "Keep your locs fresh", "https://picsum.photos/seed/card-b/600/800"),
|
|
80
|
+
makeService("colour-treatment", "Colour Treatment", "Add dimension and depth", "https://picsum.photos/seed/card-c/600/800"),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export const Default: Story = {
|
|
86
|
+
render: (args) => ({
|
|
87
|
+
components: { ServicesCardGrid },
|
|
88
|
+
setup() {
|
|
89
|
+
return { args, sampleServices };
|
|
90
|
+
},
|
|
91
|
+
template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
|
|
92
|
+
}),
|
|
93
|
+
parameters: {
|
|
94
|
+
docs: {
|
|
95
|
+
description: {
|
|
96
|
+
story: "Default three-card grid — columns auto-fit above the 250px minimum width.",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const CustomButtonText: Story = {
|
|
103
|
+
name: "Custom Button Text",
|
|
104
|
+
args: {
|
|
105
|
+
hrefBase: "/services/",
|
|
106
|
+
buttonTextPrefix: "More about",
|
|
107
|
+
},
|
|
108
|
+
render: (args) => ({
|
|
109
|
+
components: { ServicesCardGrid },
|
|
110
|
+
setup() {
|
|
111
|
+
return { args, sampleServices };
|
|
112
|
+
},
|
|
113
|
+
template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
|
|
114
|
+
}),
|
|
115
|
+
parameters: {
|
|
116
|
+
docs: {
|
|
117
|
+
description: {
|
|
118
|
+
story: "Override hrefBase and buttonTextPrefix to match the consuming app's route structure and copy.",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const ConfigOverrides: Story = {
|
|
125
|
+
name: "Config Overrides",
|
|
126
|
+
args: {
|
|
127
|
+
eyebrowConfig: { tag: "p", fontSize: "small" },
|
|
128
|
+
heroConfig: { tag: "h3", fontSize: "title" },
|
|
129
|
+
},
|
|
130
|
+
render: (args) => ({
|
|
131
|
+
components: { ServicesCardGrid },
|
|
132
|
+
setup() {
|
|
133
|
+
return { args, sampleServices };
|
|
134
|
+
},
|
|
135
|
+
template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
|
|
136
|
+
}),
|
|
137
|
+
parameters: {
|
|
138
|
+
docs: {
|
|
139
|
+
description: {
|
|
140
|
+
story:
|
|
141
|
+
"eyebrowConfig and heroConfig are applied uniformly to every card — set heroConfig.tag to h3 when the page already has an h2 above the grid.",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const SingleCard: Story = {
|
|
148
|
+
name: "Single Card",
|
|
149
|
+
render: (args) => ({
|
|
150
|
+
components: { ServicesCardGrid },
|
|
151
|
+
setup() {
|
|
152
|
+
return { args, sampleServices };
|
|
153
|
+
},
|
|
154
|
+
template: `<ServicesCardGrid v-bind="args" :services-data="[sampleServices[0]]" />`,
|
|
155
|
+
}),
|
|
156
|
+
parameters: {
|
|
157
|
+
docs: {
|
|
158
|
+
description: {
|
|
159
|
+
story: "Grid with a single service — the card expands to fill the full column width.",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const EmptyData: Story = {
|
|
166
|
+
name: "Empty Data",
|
|
167
|
+
render: (args) => ({
|
|
168
|
+
components: { ServicesCardGrid },
|
|
169
|
+
setup() {
|
|
170
|
+
return { args };
|
|
171
|
+
},
|
|
172
|
+
template: `<ServicesCardGrid v-bind="args" :services-data="[]" />`,
|
|
173
|
+
}),
|
|
174
|
+
parameters: {
|
|
175
|
+
docs: {
|
|
176
|
+
description: {
|
|
177
|
+
story: "When servicesData is empty the grid renders with no children — pass an empty array as a safe fallback while data loads.",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import ServicesSectionGrid from "../ServicesSectionGrid.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import type { Service } from "~/types/types.services";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ServicesSectionGrid> = {
|
|
6
|
+
title: "Organisms/Services/Services Section Grid",
|
|
7
|
+
component: ServicesSectionGrid,
|
|
8
|
+
argTypes: {
|
|
9
|
+
tag: {
|
|
10
|
+
control: { type: "select" },
|
|
11
|
+
options: ["div", "section", "main"],
|
|
12
|
+
description: "HTML element rendered as the root",
|
|
13
|
+
},
|
|
14
|
+
useAlternateReverse: {
|
|
15
|
+
control: { type: "boolean" },
|
|
16
|
+
description: "Alternate image/content column order on every other section",
|
|
17
|
+
},
|
|
18
|
+
summaryAlignment: {
|
|
19
|
+
control: { type: "select" },
|
|
20
|
+
options: ["start", "center", "end"],
|
|
21
|
+
description: "Vertical alignment of the info column in each section",
|
|
22
|
+
},
|
|
23
|
+
styleClassPassthrough: {
|
|
24
|
+
control: "object",
|
|
25
|
+
description: "Additional CSS classes applied to the root element",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
args: {
|
|
29
|
+
tag: "div",
|
|
30
|
+
useAlternateReverse: false,
|
|
31
|
+
summaryAlignment: "center",
|
|
32
|
+
styleClassPassthrough: [],
|
|
33
|
+
},
|
|
34
|
+
parameters: {
|
|
35
|
+
docs: {
|
|
36
|
+
description: {
|
|
37
|
+
component:
|
|
38
|
+
"Renders a vertical stack of `ServicesSection` components (summary mode) from a `Service[]` array. Each section includes a summary-link and a CTA button. Alternate image/content column order is controlled via `useAlternateReverse`.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
45
|
+
type Story = StoryObj<typeof ServicesSectionGrid>;
|
|
46
|
+
|
|
47
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const makeService = (slug: string, title: string, subtitle: string, image: string): Service => ({
|
|
50
|
+
slug,
|
|
51
|
+
title,
|
|
52
|
+
subtitle,
|
|
53
|
+
price: "£120",
|
|
54
|
+
duration: "3–4 hours",
|
|
55
|
+
image,
|
|
56
|
+
shortDescription: `Professional ${title.toLowerCase()} service tailored to your hair type and style goals.`,
|
|
57
|
+
longDescription: "Full description available on the service detail page.",
|
|
58
|
+
heroHeading: [{ text: `Why choose ${title}?`, styleClass: "normal" }],
|
|
59
|
+
whatIsIt: `${title} is a popular protective style suitable for all hair types.`,
|
|
60
|
+
process: ["Consultation", "Preparation", "Application", "Finishing"],
|
|
61
|
+
idealFor: ["Natural hair", "All textures"],
|
|
62
|
+
maintenance: "Regular maintenance recommended every 4–6 weeks.",
|
|
63
|
+
faqs: [{ question: "How long does it last?", answer: "Results vary by hair type and aftercare." }],
|
|
64
|
+
seoTitle: title,
|
|
65
|
+
seoDescription: `Book a ${title.toLowerCase()} appointment today.`,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const sampleServices: Service[] = [
|
|
69
|
+
makeService("locs-installation", "Locs Installation", "Start your loc journey", "https://picsum.photos/seed/locs-a/800/600"),
|
|
70
|
+
makeService("locs-retwist", "Locs Retwist", "Keep your locs fresh", "https://picsum.photos/seed/locs-b/800/600"),
|
|
71
|
+
makeService("colour-treatment", "Colour Treatment", "Add dimension and depth", "https://picsum.photos/seed/colour/800/600"),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export const Default: Story = {
|
|
77
|
+
render: (args) => ({
|
|
78
|
+
components: { ServicesSectionGrid },
|
|
79
|
+
setup() {
|
|
80
|
+
return { args, sampleServices };
|
|
81
|
+
},
|
|
82
|
+
template: `
|
|
83
|
+
<ServicesSectionGrid v-bind="args" :services-data="sampleServices">
|
|
84
|
+
<template #summary-link="{ serviceData }">
|
|
85
|
+
<a
|
|
86
|
+
:href="'/services/' + serviceData.slug"
|
|
87
|
+
style="display:inline-flex;align-items:center;gap:0.4rem;margin-top:1.6rem;color:inherit;"
|
|
88
|
+
>
|
|
89
|
+
More about {{ serviceData.title }} →
|
|
90
|
+
</a>
|
|
91
|
+
</template>
|
|
92
|
+
</ServicesSectionGrid>
|
|
93
|
+
`,
|
|
94
|
+
}),
|
|
95
|
+
parameters: {
|
|
96
|
+
docs: {
|
|
97
|
+
description: {
|
|
98
|
+
story:
|
|
99
|
+
"summary-link slot receives serviceData as a scoped prop so the href and label can be built from the service slug and title.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const AlternateReverse: Story = {
|
|
106
|
+
name: "Alternate Reverse",
|
|
107
|
+
args: {
|
|
108
|
+
useAlternateReverse: true,
|
|
109
|
+
},
|
|
110
|
+
render: (args) => ({
|
|
111
|
+
components: { ServicesSectionGrid },
|
|
112
|
+
setup() {
|
|
113
|
+
return { args, sampleServices };
|
|
114
|
+
},
|
|
115
|
+
template: `
|
|
116
|
+
<ServicesSectionGrid v-bind="args" :services-data="sampleServices">
|
|
117
|
+
<template #summary-link="{ serviceData }">
|
|
118
|
+
<a :href="'/services/' + serviceData.slug" style="display:inline-flex;margin-top:1.6rem;color:inherit;">
|
|
119
|
+
More about {{ serviceData.title }} →
|
|
120
|
+
</a>
|
|
121
|
+
</template>
|
|
122
|
+
</ServicesSectionGrid>
|
|
123
|
+
`,
|
|
124
|
+
}),
|
|
125
|
+
parameters: {
|
|
126
|
+
docs: {
|
|
127
|
+
description: {
|
|
128
|
+
story:
|
|
129
|
+
"useAlternateReverse — odd-indexed sections flip image/content columns, creating a zigzag layout for visual variety.",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const NoSlots: Story = {
|
|
136
|
+
name: "No Slots (empty sections)",
|
|
137
|
+
render: (args) => ({
|
|
138
|
+
components: { ServicesSectionGrid },
|
|
139
|
+
setup() {
|
|
140
|
+
return { args, sampleServices };
|
|
141
|
+
},
|
|
142
|
+
template: `<ServicesSectionGrid v-bind="args" :services-data="sampleServices" />`,
|
|
143
|
+
}),
|
|
144
|
+
parameters: {
|
|
145
|
+
docs: {
|
|
146
|
+
description: {
|
|
147
|
+
story:
|
|
148
|
+
"Without slots provided the summary-link and cta areas render empty — use this to verify layout before wiring up navigation.",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const SingleService: Story = {
|
|
155
|
+
name: "Single Service",
|
|
156
|
+
render: (args) => ({
|
|
157
|
+
components: { ServicesSectionGrid },
|
|
158
|
+
setup() {
|
|
159
|
+
return { args, sampleServices };
|
|
160
|
+
},
|
|
161
|
+
template: `
|
|
162
|
+
<ServicesSectionGrid v-bind="args" :services-data="[sampleServices[0]]">
|
|
163
|
+
<template #summary-link="{ serviceData }">
|
|
164
|
+
<a :href="'/services/' + serviceData.slug" style="display:inline-flex;margin-top:1.6rem;color:inherit;">
|
|
165
|
+
More about {{ serviceData.title }} →
|
|
166
|
+
</a>
|
|
167
|
+
</template>
|
|
168
|
+
</ServicesSectionGrid>
|
|
169
|
+
`,
|
|
170
|
+
}),
|
|
171
|
+
parameters: {
|
|
172
|
+
docs: {
|
|
173
|
+
description: {
|
|
174
|
+
story: "Grid with a single service item.",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const EmptyData: Story = {
|
|
181
|
+
name: "Empty Data",
|
|
182
|
+
render: (args) => ({
|
|
183
|
+
components: { ServicesSectionGrid },
|
|
184
|
+
setup() {
|
|
185
|
+
return { args };
|
|
186
|
+
},
|
|
187
|
+
template: `<ServicesSectionGrid v-bind="args" :services-data="[]" />`,
|
|
188
|
+
}),
|
|
189
|
+
parameters: {
|
|
190
|
+
docs: {
|
|
191
|
+
description: {
|
|
192
|
+
story: "When servicesData is empty the grid renders with no children — pass an empty array as a safe fallback while data loads.",
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import ServicesCardGrid from "../ServicesCardGrid.vue";
|
|
4
|
+
import type { Service } from "~/types/types.services";
|
|
5
|
+
|
|
6
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const makeService = (slug: string, title: string): Service => ({
|
|
9
|
+
slug,
|
|
10
|
+
title,
|
|
11
|
+
subtitle: `Subtitle for ${title}`,
|
|
12
|
+
price: "£50",
|
|
13
|
+
duration: "60 mins",
|
|
14
|
+
image: `/images/${slug}.jpg`,
|
|
15
|
+
shortDescription: `Short description for ${title}.`,
|
|
16
|
+
longDescription: "Long description.",
|
|
17
|
+
heroHeading: [{ text: title, styleClass: "normal" }],
|
|
18
|
+
whatIsIt: "What this service is.",
|
|
19
|
+
process: ["Step one"],
|
|
20
|
+
idealFor: ["Everyone"],
|
|
21
|
+
maintenance: "Maintenance advice.",
|
|
22
|
+
faqs: [{ question: "A question?", answer: "An answer." }],
|
|
23
|
+
seoTitle: title,
|
|
24
|
+
seoDescription: `SEO description for ${title}.`,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const mockServices: Service[] = [
|
|
28
|
+
makeService("locs-installation", "Locs Installation"),
|
|
29
|
+
makeService("locs-retwist", "Locs Retwist"),
|
|
30
|
+
makeService("colour-treatment", "Colour Treatment"),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("ServicesCardGrid", () => {
|
|
36
|
+
// ─── Mount ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
it("mounts without error", async () => {
|
|
39
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
40
|
+
props: { servicesData: mockServices },
|
|
41
|
+
});
|
|
42
|
+
expect(wrapper.vm).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders correct HTML structure", async () => {
|
|
46
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
47
|
+
props: { servicesData: mockServices },
|
|
48
|
+
});
|
|
49
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ─── Root element ───────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
it("has services-card-grid class on root", async () => {
|
|
55
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
56
|
+
props: { servicesData: mockServices },
|
|
57
|
+
});
|
|
58
|
+
expect(wrapper.classes()).toContain("services-card-grid");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders as a div by default", async () => {
|
|
62
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
63
|
+
props: { servicesData: mockServices },
|
|
64
|
+
});
|
|
65
|
+
expect(wrapper.element.tagName).toBe("DIV");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("renders the tag prop as the root element", async () => {
|
|
69
|
+
for (const tag of ["section", "main"] as const) {
|
|
70
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
71
|
+
props: { servicesData: mockServices, tag },
|
|
72
|
+
});
|
|
73
|
+
expect(wrapper.element.tagName).toBe(tag.toUpperCase());
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─── Children ───────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
it("renders the correct number of service cards", async () => {
|
|
80
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
81
|
+
props: { servicesData: mockServices },
|
|
82
|
+
});
|
|
83
|
+
expect(wrapper.findAll(".services-card")).toHaveLength(mockServices.length);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders no cards when servicesData is empty", async () => {
|
|
87
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
88
|
+
props: { servicesData: [] },
|
|
89
|
+
});
|
|
90
|
+
expect(wrapper.findAll(".services-card")).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders a single card correctly", async () => {
|
|
94
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
95
|
+
props: { servicesData: [mockServices[0]!] },
|
|
96
|
+
});
|
|
97
|
+
expect(wrapper.findAll(".services-card")).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ─── hrefBase ───────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
it("builds card href from default hrefBase and service slug", async () => {
|
|
103
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
104
|
+
props: { servicesData: [mockServices[0]!] },
|
|
105
|
+
});
|
|
106
|
+
const link = wrapper.find("a");
|
|
107
|
+
expect(link.attributes("href")).toContain(mockServices[0]!.slug);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("uses a custom hrefBase in the card button href", async () => {
|
|
111
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
112
|
+
props: { servicesData: [mockServices[0]!], hrefBase: "/services/" },
|
|
113
|
+
});
|
|
114
|
+
const link = wrapper.find("a");
|
|
115
|
+
expect(link.attributes("href")).toBe(`/services/${mockServices[0]!.slug}`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── buttonTextPrefix ───────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
it("uses default buttonTextPrefix in card button text", async () => {
|
|
121
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
122
|
+
props: { servicesData: [mockServices[0]!] },
|
|
123
|
+
});
|
|
124
|
+
expect(wrapper.text()).toContain(`Enquire about ${mockServices[0]!.title}`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("uses a custom buttonTextPrefix in card button text", async () => {
|
|
128
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
129
|
+
props: { servicesData: [mockServices[0]!], buttonTextPrefix: "Learn more about" },
|
|
130
|
+
});
|
|
131
|
+
expect(wrapper.text()).toContain(`Learn more about ${mockServices[0]!.title}`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ─── eyebrowConfig / heroConfig ─────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
it("passes eyebrowConfig fontSize to each card", async () => {
|
|
137
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
138
|
+
props: { servicesData: mockServices, eyebrowConfig: { fontSize: "small" } },
|
|
139
|
+
});
|
|
140
|
+
wrapper.findAll(".eyebrow-text").forEach((el) => {
|
|
141
|
+
expect(el.classes()).toContain("small");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("passes heroConfig tag to each card", async () => {
|
|
146
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
147
|
+
props: { servicesData: mockServices, heroConfig: { tag: "h3" } },
|
|
148
|
+
});
|
|
149
|
+
wrapper.findAll(".hero-text").forEach((el) => {
|
|
150
|
+
expect(el.element.tagName).toBe("H3");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ─── styleClassPassthrough ──────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
it("applies a single styleClassPassthrough string", async () => {
|
|
157
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
158
|
+
props: { servicesData: mockServices, styleClassPassthrough: "custom-grid" },
|
|
159
|
+
});
|
|
160
|
+
expect(wrapper.classes()).toContain("custom-grid");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
164
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
165
|
+
props: { servicesData: mockServices, styleClassPassthrough: ["class-a", "class-b"] },
|
|
166
|
+
});
|
|
167
|
+
expect(wrapper.classes()).toContain("class-a");
|
|
168
|
+
expect(wrapper.classes()).toContain("class-b");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
172
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
173
|
+
props: { servicesData: mockServices, styleClassPassthrough: ["original"] },
|
|
174
|
+
});
|
|
175
|
+
expect(wrapper.classes()).toContain("original");
|
|
176
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
177
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
178
|
+
expect(wrapper.classes()).toContain("updated");
|
|
179
|
+
});
|
|
180
|
+
});
|