srcdev-nuxt-components 9.5.7 → 9.6.0
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/components/breadcrumb.md +5 -0
- package/.claude/skills/components/display-pill.md +2 -0
- package/.claude/skills/components/input-button.md +3 -0
- package/.claude/skills/components/responsive-header.md +49 -0
- package/.claude/skills/components/service-detail.md +32 -0
- package/.claude/skills/components/service-summary.md +8 -0
- package/.claude/skills/components/services-card-grid.md +5 -3
- package/.claude/skills/components/services-card.md +22 -12
- package/.claude/skills/components/slider-gallery.md +10 -4
- package/.claude/skills/components/tab-navigation.md +25 -9
- package/.claude/skills/index.md +1 -2
- package/.vscode/srcdev-component-responsive-header.code-snippets +10 -0
- package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
- package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
- package/.vscode/srcdev-component-services-card.code-snippets +38 -36
- package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
- package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
- package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
- package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
- package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
- package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +4 -0
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +33 -4
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +51 -4
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
- package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
- package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
- package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
- package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
- package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
- package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/input-button/InputButton.vue +3 -1
- package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
- package/app/composables/useAnchorScroll.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/site-navigation.md +0 -145
- package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
- package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
- package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
- package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
- package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
|
@@ -34,7 +34,16 @@
|
|
|
34
34
|
role="none"
|
|
35
35
|
@mouseenter="hoveredItemKey = `${String(groupKey)}-${localIndex}`"
|
|
36
36
|
>
|
|
37
|
-
<
|
|
37
|
+
<a
|
|
38
|
+
v-if="isAnchorPath(link.path)"
|
|
39
|
+
class="overflow-navigation-link"
|
|
40
|
+
:href="link.path"
|
|
41
|
+
role="menuitem"
|
|
42
|
+
@click="emit('anchorClick', $event, link.path)"
|
|
43
|
+
>
|
|
44
|
+
<span class="overflow-navigation-text">{{ link.name }}</span>
|
|
45
|
+
</a>
|
|
46
|
+
<NuxtLink v-else class="overflow-navigation-link" :to="link.path" role="menuitem">
|
|
38
47
|
<span class="overflow-navigation-text">{{ link.name }}</span>
|
|
39
48
|
</NuxtLink>
|
|
40
49
|
</li>
|
|
@@ -87,7 +96,16 @@
|
|
|
87
96
|
}"
|
|
88
97
|
@mouseenter="hoveredChildKey = `${String(groupKey)}-${localIndex}-${childIndex}`"
|
|
89
98
|
>
|
|
90
|
-
<
|
|
99
|
+
<a
|
|
100
|
+
v-if="childLink.path && isAnchorPath(childLink.path)"
|
|
101
|
+
:href="childLink.path"
|
|
102
|
+
class="overflow-navigation-sub-nav-link"
|
|
103
|
+
role="menuitem"
|
|
104
|
+
@click="emit('anchorClick', $event, childLink.path)"
|
|
105
|
+
>
|
|
106
|
+
<span class="overflow-navigation-sub-nav-text">{{ childLink.name }}</span>
|
|
107
|
+
</a>
|
|
108
|
+
<NuxtLink v-else :to="childLink.path" class="overflow-navigation-sub-nav-link" role="menuitem">
|
|
91
109
|
<span class="overflow-navigation-sub-nav-text">{{ childLink.name }}</span>
|
|
92
110
|
</NuxtLink>
|
|
93
111
|
</li>
|
|
@@ -117,6 +135,8 @@ interface Props {
|
|
|
117
135
|
ariaLabel?: string;
|
|
118
136
|
/** aria-label on each submenu summary; {title} is replaced with the item's title. */
|
|
119
137
|
submenuAriaLabel?: string;
|
|
138
|
+
/** The active "#anchor" path, owned by ResponsiveHeader so both menus agree. */
|
|
139
|
+
activeHash?: string;
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -128,8 +148,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
128
148
|
styleClassPassthrough: () => [],
|
|
129
149
|
ariaLabel: "Overflow navigation menu",
|
|
130
150
|
submenuAriaLabel: "{title} submenu",
|
|
151
|
+
activeHash: "",
|
|
131
152
|
});
|
|
132
153
|
|
|
154
|
+
const emit = defineEmits<{
|
|
155
|
+
anchorClick: [event: MouseEvent, href: string];
|
|
156
|
+
}>();
|
|
157
|
+
|
|
133
158
|
const submenuLabel = (link: ResponsiveHeaderNavItem) =>
|
|
134
159
|
props.submenuAriaLabel.replace("{title}", link.childLinksTitle ?? link.name);
|
|
135
160
|
|
|
@@ -162,9 +187,13 @@ onUnmounted(() => {
|
|
|
162
187
|
|
|
163
188
|
const route = useRoute();
|
|
164
189
|
|
|
190
|
+
const isAnchorPath = (path?: string) => Boolean(path?.startsWith("#"));
|
|
191
|
+
const isCurrentPath = (path?: string) =>
|
|
192
|
+
Boolean(path) && (isAnchorPath(path) ? path === props.activeHash : path === route.path);
|
|
193
|
+
|
|
165
194
|
const isActiveNavItem = (link: ResponsiveHeaderNavItem): boolean => {
|
|
166
|
-
if (link.path) return
|
|
167
|
-
if (link.childLinks) return link.childLinks.some((child) => child.path
|
|
195
|
+
if (link.path) return isCurrentPath(link.path);
|
|
196
|
+
if (link.childLinks) return link.childLinks.some((child) => isCurrentPath(child.path));
|
|
168
197
|
return false;
|
|
169
198
|
};
|
|
170
199
|
// const DETAILS_ANIMATION_DURATION_STRING = `${DETAILS_ANIMATION_DURATION}ms` as const;
|
|
@@ -39,8 +39,18 @@
|
|
|
39
39
|
@mouseenter="handleNavigationItemHover(`${String(groupKey)}-${localIndex}`)"
|
|
40
40
|
@focusin="handleNavigationItemHover(`${String(groupKey)}-${localIndex}`)"
|
|
41
41
|
>
|
|
42
|
+
<a
|
|
43
|
+
v-if="link.path && isAnchorPath(link.path)"
|
|
44
|
+
class="main-navigation-link"
|
|
45
|
+
:class="{ 'has-icon': link.iconName }"
|
|
46
|
+
:href="link.path"
|
|
47
|
+
@click="handleAnchorClick($event, link.path)"
|
|
48
|
+
>
|
|
49
|
+
<Icon v-if="link.iconName" :name="link.iconName" class="decorator-icon" aria-hidden="true" />
|
|
50
|
+
{{ link.name }}
|
|
51
|
+
</a>
|
|
42
52
|
<NuxtLink
|
|
43
|
-
v-if="link.path"
|
|
53
|
+
v-else-if="link.path"
|
|
44
54
|
class="main-navigation-link"
|
|
45
55
|
:class="{ 'has-icon': link.iconName }"
|
|
46
56
|
:to="link.path"
|
|
@@ -69,7 +79,16 @@
|
|
|
69
79
|
:key="childIndex"
|
|
70
80
|
class="main-navigation-sub-nav-item"
|
|
71
81
|
>
|
|
72
|
-
<
|
|
82
|
+
<a
|
|
83
|
+
v-if="childLink.path && isAnchorPath(childLink.path)"
|
|
84
|
+
:href="childLink.path"
|
|
85
|
+
class="main-navigation-sub-nav-link"
|
|
86
|
+
role="menuitem"
|
|
87
|
+
@click="handleAnchorClick($event, childLink.path)"
|
|
88
|
+
>
|
|
89
|
+
{{ childLink.name }}
|
|
90
|
+
</a>
|
|
91
|
+
<NuxtLink v-else :to="childLink.path" class="main-navigation-sub-nav-link" role="menuitem">
|
|
73
92
|
{{ childLink.name }}
|
|
74
93
|
</NuxtLink>
|
|
75
94
|
</li>
|
|
@@ -108,6 +127,8 @@
|
|
|
108
127
|
:panel-variant="panelVariant"
|
|
109
128
|
:aria-label="overflowMenuAriaLabel"
|
|
110
129
|
:submenu-aria-label="submenuAriaLabel"
|
|
130
|
+
:active-hash="activeHash"
|
|
131
|
+
@anchor-click="handleAnchorClick"
|
|
111
132
|
/>
|
|
112
133
|
</div>
|
|
113
134
|
</details>
|
|
@@ -144,6 +165,11 @@ interface Props {
|
|
|
144
165
|
overflowButtonLabel?: string;
|
|
145
166
|
/** aria-label on each dropdown summary; {title} is replaced with the item's title. Forwarded to NavigationItems. */
|
|
146
167
|
submenuAriaLabel?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Pixels to leave above a section when a same-page "#anchor" link scrolls to it (e.g. a sticky
|
|
170
|
+
* header's height). Pass a getter to read it at click time.
|
|
171
|
+
*/
|
|
172
|
+
anchorScrollOffset?: number | (() => number);
|
|
147
173
|
}
|
|
148
174
|
|
|
149
175
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -165,6 +191,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
165
191
|
overflowMenuAriaLabel: "Overflow navigation menu",
|
|
166
192
|
overflowButtonLabel: "More navigation",
|
|
167
193
|
submenuAriaLabel: "{title} submenu",
|
|
194
|
+
anchorScrollOffset: undefined,
|
|
168
195
|
});
|
|
169
196
|
|
|
170
197
|
const submenuLabel = (link: ResponsiveHeaderNavItem) =>
|
|
@@ -315,12 +342,32 @@ const hoveredItemKey = ref<string | null>(null);
|
|
|
315
342
|
|
|
316
343
|
const route = useRoute();
|
|
317
344
|
|
|
345
|
+
// "#anchor" paths are same-page section links: plain <a>, smooth-scrolled, active by hash.
|
|
346
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offset: props.anchorScrollOffset });
|
|
347
|
+
const isAnchorPath = (path?: string) => Boolean(path?.startsWith("#"));
|
|
348
|
+
const isCurrentPath = (path?: string) =>
|
|
349
|
+
Boolean(path) && (isAnchorPath(path) ? path === activeHash.value : path === route.path);
|
|
350
|
+
|
|
318
351
|
const isActiveNavItem = (link: ResponsiveHeaderNavItem): boolean => {
|
|
319
|
-
if (link.path) return
|
|
320
|
-
if (link.childLinks) return link.childLinks.some((child) => child.path
|
|
352
|
+
if (link.path) return isCurrentPath(link.path);
|
|
353
|
+
if (link.childLinks) return link.childLinks.some((child) => isCurrentPath(child.path));
|
|
321
354
|
return false;
|
|
322
355
|
};
|
|
323
356
|
|
|
357
|
+
const handleAnchorClick = (event: MouseEvent, href: string) => {
|
|
358
|
+
handleNavClick(event, href);
|
|
359
|
+
closeAllNavigationDetails();
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
onMounted(() => {
|
|
363
|
+
if (activeHash.value) return;
|
|
364
|
+
const firstAnchor = Object.values(props.responsiveNavLinks)
|
|
365
|
+
.flat()
|
|
366
|
+
.flatMap((link) => [link, ...(link.childLinks ?? [])])
|
|
367
|
+
.find((link) => isAnchorPath(link.path));
|
|
368
|
+
if (firstAnchor?.path) activeHash.value = firstAnchor.path;
|
|
369
|
+
});
|
|
370
|
+
|
|
324
371
|
const isAnimated = ref(true);
|
|
325
372
|
|
|
326
373
|
// Local (non-useState) flag — resets to false on every mount.
|
|
@@ -82,6 +82,11 @@ const meta: Meta<StoryArgs> = {
|
|
|
82
82
|
description: "aria-label on each dropdown summary; {title} is replaced with the item's title",
|
|
83
83
|
table: { category: "Labels", defaultValue: { summary: "{title} submenu" } },
|
|
84
84
|
},
|
|
85
|
+
anchorScrollOffset: {
|
|
86
|
+
control: { type: "number", min: 0, step: 10 },
|
|
87
|
+
description: "Pixels left above a section when a #anchor link scrolls to it (e.g. a sticky header's height)",
|
|
88
|
+
table: { category: "Behaviour", defaultValue: { summary: "0" } },
|
|
89
|
+
},
|
|
85
90
|
styleClassPassthrough: {
|
|
86
91
|
control: "object",
|
|
87
92
|
description: "Extra CSS classes applied to the root element",
|
|
@@ -200,6 +205,53 @@ export const MainNavAlignment: Story = {
|
|
|
200
205
|
},
|
|
201
206
|
};
|
|
202
207
|
|
|
208
|
+
export const SamePageAnchorLinks: Story = {
|
|
209
|
+
name: "Same-Page Anchor Links",
|
|
210
|
+
args: {
|
|
211
|
+
responsiveNavLinks: {
|
|
212
|
+
main: [
|
|
213
|
+
{ name: "Home", path: "#rh-home" },
|
|
214
|
+
{ name: "About", path: "#rh-about" },
|
|
215
|
+
{ name: "Testimonials", path: "#rh-testimonials" },
|
|
216
|
+
{
|
|
217
|
+
name: "More",
|
|
218
|
+
childLinks: [
|
|
219
|
+
{ name: "Prices", path: "#rh-prices" },
|
|
220
|
+
{ name: "Blog (a route)", path: "/blog" },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
{ name: "Contact", path: "#rh-contact" },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
anchorScrollOffset: 80,
|
|
227
|
+
},
|
|
228
|
+
render: renderWith(`
|
|
229
|
+
<div style="position: sticky; top: 0; z-index: 2; background: var(--page-bg, white); border-block-end: 1px solid currentColor;">
|
|
230
|
+
<ResponsiveHeader v-bind="componentArgs" />
|
|
231
|
+
</div>
|
|
232
|
+
<section
|
|
233
|
+
v-for="id in ['rh-home', 'rh-about', 'rh-testimonials', 'rh-prices', 'rh-contact']"
|
|
234
|
+
:id="id"
|
|
235
|
+
:key="id"
|
|
236
|
+
style="min-block-size: 70vh; padding: 2.4rem; border-block-end: 1px dashed currentColor;"
|
|
237
|
+
>
|
|
238
|
+
<h2 style="margin: 0;">#{{ id }}</h2>
|
|
239
|
+
</section>
|
|
240
|
+
`),
|
|
241
|
+
parameters: {
|
|
242
|
+
docs: {
|
|
243
|
+
description: {
|
|
244
|
+
story:
|
|
245
|
+
"Items whose `path` starts with `#` are same-page section links: they render as plain `<a>` (not NuxtLink), " +
|
|
246
|
+
"smooth-scroll to the section (an instant jump under reduced motion), and are active by hash rather than by " +
|
|
247
|
+
"route; the first anchor is active on load. `anchorScrollOffset` (80 here) leaves room for the sticky header. " +
|
|
248
|
+
"Route links like Blog mix in as normal. Narrow the canvas so items move into the overflow menu: anchor links " +
|
|
249
|
+
"there scroll the same way and close the menu. This replaces TabNavigation's anchor mode.",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
203
255
|
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
204
256
|
|
|
205
257
|
const stressNavLinks: ResponsiveHeaderProp = {
|
|
@@ -168,4 +168,24 @@ describe("NavigationItems", () => {
|
|
|
168
168
|
});
|
|
169
169
|
expect(wrapper.html()).toMatchSnapshot();
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
// ─── Same-page anchor links ─────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
it("renders #anchor items as plain links, emits anchorClick and follows activeHash", async () => {
|
|
175
|
+
const wrapper = await mountSuspended(NavigationItems, {
|
|
176
|
+
props: {
|
|
177
|
+
activeHash: "#about",
|
|
178
|
+
mainNavigationState: {
|
|
179
|
+
clonedNavLinks: { firstNav: [{ name: "Home", path: "#home" }, { name: "About", path: "#about" }] },
|
|
180
|
+
navListVisibility: { firstNav: false },
|
|
181
|
+
hasSecondNav: false,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const links = wrapper.findAll(".overflow-navigation-link");
|
|
186
|
+
expect(links[0]!.attributes("href")).toBe("#home");
|
|
187
|
+
expect(wrapper.findAll(".overflow-navigation-item")[1]!.classes()).toContain("is-active");
|
|
188
|
+
await links[0]!.trigger("click");
|
|
189
|
+
expect(wrapper.emitted("anchorClick")?.[0]?.[1]).toBe("#home");
|
|
190
|
+
});
|
|
171
191
|
});
|
|
@@ -401,4 +401,44 @@ describe("ResponsiveHeader", () => {
|
|
|
401
401
|
});
|
|
402
402
|
expect(wrapper.html()).toMatchSnapshot();
|
|
403
403
|
});
|
|
404
|
+
|
|
405
|
+
// ─── Same-page anchor links ─────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
describe("anchor links", () => {
|
|
408
|
+
const anchorLinks: ResponsiveHeaderProp = {
|
|
409
|
+
firstNav: [
|
|
410
|
+
{ name: "Home", path: "#home" },
|
|
411
|
+
{ name: "About", path: "#about" },
|
|
412
|
+
{ name: "Blog", path: "/blog" },
|
|
413
|
+
],
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
it("renders #anchor paths as plain links and route paths as NuxtLinks", async () => {
|
|
417
|
+
const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
|
|
418
|
+
const links = wrapper.findAll(".main-navigation-link");
|
|
419
|
+
expect(links[0]!.attributes("href")).toBe("#home");
|
|
420
|
+
expect(links[2]!.attributes("href")).toBe("/blog");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("marks the first anchor item active on load", async () => {
|
|
424
|
+
const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
|
|
425
|
+
await nextTick();
|
|
426
|
+
const items = wrapper.findAll(".main-navigation-item");
|
|
427
|
+
expect(items[0]!.classes()).toContain("is-active");
|
|
428
|
+
expect(items[1]!.classes()).not.toContain("is-active");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("moves the active item to a clicked anchor and scrolls instead of jumping", async () => {
|
|
432
|
+
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
|
|
433
|
+
const section = document.createElement("section");
|
|
434
|
+
section.id = "about";
|
|
435
|
+
section.scrollIntoView = vi.fn();
|
|
436
|
+
document.body.appendChild(section);
|
|
437
|
+
const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
|
|
438
|
+
await wrapper.findAll(".main-navigation-link")[1]!.trigger("click");
|
|
439
|
+
expect(section.scrollIntoView).toHaveBeenCalled();
|
|
440
|
+
expect(wrapper.findAll(".main-navigation-item")[1]!.classes()).toContain("is-active");
|
|
441
|
+
section.remove();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
404
444
|
});
|
|
@@ -1,40 +1,8 @@
|
|
|
1
|
+
# ServiceDetail — Consumer Styling Guide
|
|
1
2
|
|
|
2
3
|
> **Changed 2026-09-27**: `--service-detail-hero-pill-bg` (and the hero text colour on the pills)
|
|
3
4
|
> never applied before, because `DisplayPill`'s `neutral` variant ignored the base tokens. It now
|
|
4
5
|
> falls back to them, so the hero pills are transparent with the hero text colour as documented.
|
|
5
|
-
# ServiceDetail — Consumer Styling Guide
|
|
6
|
-
|
|
7
|
-
## Layout building blocks
|
|
8
|
-
|
|
9
|
-
The hero banner is a [GridStack](../../../../../.claude/skills/components/grid-stack.md) with two
|
|
10
|
-
stacked layers — an `image` layer (the service photo) behind a `content` layer (the scrim +
|
|
11
|
-
breadcrumb/title/pills). Each layer's root element is itself a
|
|
12
|
-
[PageRow](../../../../../.claude/skills/components/page-row.md): `.service-detail__hero-image-row`
|
|
13
|
-
(prop `heroImageVariant`, default `"full"`) wraps the photo, and `.service-detail__hero-overlay`
|
|
14
|
-
(prop `heroContentVariant`, default `"content"`) *is* the scrim/alignment element, not a wrapper
|
|
15
|
-
around one — a `PageRow`'s own box always spans the full width of its parent regardless of variant,
|
|
16
|
-
only its children default into the variant's grid column, so the one element can carry the
|
|
17
|
-
full-bleed scrim while its child (`.service-detail__hero-content`) still gets pulled to the
|
|
18
|
-
configured column. This means the photo bleeds to whatever width `.service-detail` itself is allowed
|
|
19
|
-
(edge-to-edge if `ServiceDetail` is placed without a content-constraining wrapper around it), while
|
|
20
|
-
the breadcrumb/title/pills always stay pulled into the page's normal content-width column,
|
|
21
|
-
regardless of how wide the photo bleeds. `--service-detail-hero-padding` now only controls
|
|
22
|
-
**block** (vertical) padding on the scrim — horizontal alignment of the text is handled entirely by
|
|
23
|
-
`.service-detail__hero-overlay`'s own `PageRow` grid, not by padding, so it can't fight the page's
|
|
24
|
-
content-grid gutters.
|
|
25
|
-
|
|
26
|
-
The two-column body (main content + sticky sidebar) is wrapped in its own `PageRow`, whose variant
|
|
27
|
-
is a prop: `bodyVariant` (`"full" | "popout" | "content" | "inset-content"`, default `"content"`).
|
|
28
|
-
The closing full-width CTA banner is wrapped the same way via `finalCtaVariant` (same type, same
|
|
29
|
-
`"content"` default). This means a consumer no longer needs to wrap `ServiceDetail` itself in an
|
|
30
|
-
outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
|
|
31
|
-
directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
|
|
32
|
-
inside an outer, wider `PageRow` — see
|
|
33
|
-
[service-summary.md](../../../../../.claude/skills/components/service-summary.md)-style nesting
|
|
34
|
-
notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
|
|
35
|
-
compose.
|
|
36
|
-
|
|
37
|
-
---
|
|
38
6
|
|
|
39
7
|
## Public token API
|
|
40
8
|
|
|
@@ -44,12 +12,25 @@ compose.
|
|
|
44
12
|
|---|---|---|
|
|
45
13
|
| `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero (clips the image/content layers — set to `0` for a true edge-to-edge bleed hero) |
|
|
46
14
|
| `--service-detail-hero-content-align` | `end` | `align-content` on `.service-detail__hero-content` (the breadcrumb/title/pills wrapper) — where that content sits within the hero's block axis (`end` = bottom-anchored) |
|
|
47
|
-
| `--service-detail-hero-min-height-mobile` | `32rem` |
|
|
48
|
-
| `--service-detail-hero-min-height-tablet` | `36rem` |
|
|
49
|
-
| `--service-detail-hero-min-height-desktop` | `42rem` |
|
|
15
|
+
| `--service-detail-hero-min-height-mobile` | `32rem` | Minimum hero banner height below the `768px` container breakpoint. The hero grows past it when its content (a long title, say) needs more room; the image always fills the hero (`object-fit`/`object-position` control cropping) |
|
|
16
|
+
| `--service-detail-hero-min-height-tablet` | `36rem` | Minimum hero banner height from the `768px` container breakpoint |
|
|
17
|
+
| `--service-detail-hero-min-height-desktop` | `42rem` | Minimum hero banner height from the `1024px` container breakpoint |
|
|
18
|
+
|
|
19
|
+
> Changed 2026-10-06: these were applied as a fixed `height`, so a long title was clipped by the
|
|
20
|
+
> hero. They are now a true minimum (`min-block-size`), matching their names.
|
|
21
|
+
>
|
|
22
|
+
> Also 2026-10-06: line-clamp tokens for the hero eyebrow and title, sidebar labels and values,
|
|
23
|
+
> and the final CTA heading and body. The hero title's and final CTA heading's spacing moved from
|
|
24
|
+
> utility classes (`mb-20`, `mbs-0`/`mbe-8`) into the component CSS, same values, so the clamp can
|
|
25
|
+
> keep descenders and italic overhang visible.
|
|
50
26
|
| `--service-detail-hero-padding` | `3.2rem` | **Block-axis (vertical)** padding around the breadcrumb/title/pills content — horizontal alignment comes from the nested content `PageRow`, not this token |
|
|
51
27
|
| `--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 |
|
|
52
28
|
| `--service-detail-hero-text-colour` | `white` | Colour of the breadcrumb, eyebrow, and title over the hero image |
|
|
29
|
+
| `--service-detail-hero-eyebrow-line-clamp` | `none` | Max lines of the hero eyebrow (subtitle), ellipsis on the last. `1` is single-line ellipsis |
|
|
30
|
+
| `--service-detail-hero-title-line-clamp` | `none` | Max lines of the hero title |
|
|
31
|
+
|
|
32
|
+
For the breadcrumb, set Breadcrumb's own `--breadcrumb-item-line-clamp` (it reaches the hero
|
|
33
|
+
breadcrumb from any ancestor).
|
|
53
34
|
| `--service-detail-breadcrumb-margin-block-end` | `0.8rem` | Space below the breadcrumb |
|
|
54
35
|
| `--service-detail-hero-pills-gap` | `0.8rem` | Gap between the duration and price pills |
|
|
55
36
|
| `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--display-pill-background`) |
|
|
@@ -100,8 +81,8 @@ target `--stepper-list-*` directly under `.service-detail__process` — see
|
|
|
100
81
|
| `--service-detail-ideal-for-gap` | `1.2rem` | Gap between ideal-for cards |
|
|
101
82
|
| `--service-detail-ideal-for-item-gap` | `1rem` | Gap between icon and text within a card |
|
|
102
83
|
| `--service-detail-ideal-for-item-padding` | `1.6rem` | Padding inside a card |
|
|
103
|
-
| `--service-detail-ideal-for-item-background` | `var(--
|
|
104
|
-
| `--service-detail-ideal-for-item-border-colour` | `
|
|
84
|
+
| `--service-detail-ideal-for-item-background` | `var(--theme-surface-subtle)` | Card background colour |
|
|
85
|
+
| `--service-detail-ideal-for-item-border-colour` | `transparent` | Card border colour |
|
|
105
86
|
| `--service-detail-ideal-for-item-border-radius` | `0.4rem` | Card corner rounding |
|
|
106
87
|
| `--service-detail-ideal-for-icon-size` | `1.6rem` | Icon size |
|
|
107
88
|
| `--service-detail-ideal-for-icon-colour` | `var(--colour-text-accent)` | Icon colour |
|
|
@@ -129,17 +110,28 @@ The ideal-for grid becomes 2 columns at a **500px container width**.
|
|
|
129
110
|
| `--service-detail-sidebar-row-book-cta-padding-block` | `2rem` | Vertical padding on the `book-cta` slot's own row (only renders when the slot has content) |
|
|
130
111
|
| `--service-detail-sidebar-row-book-cta-justify-content` | `end` | Horizontal alignment of the `book-cta` slot content within its row |
|
|
131
112
|
| `--service-detail-sidebar-row-label-font-size` | `1.2rem` | Font size of a row's label (e.g. "Price") |
|
|
113
|
+
| `--service-detail-sidebar-label-line-clamp` | `none` | Max lines of the "Book This Service" / "You May Also Like" labels |
|
|
114
|
+
| `--service-detail-sidebar-value-line-clamp` | `none` | Max lines of a row's value (price, duration, location) |
|
|
115
|
+
|
|
116
|
+
A row's label takes the width of its own words (up to half the row) and wraps between words only;
|
|
117
|
+
the value takes the rest and wraps anywhere.
|
|
132
118
|
| `--service-detail-sidebar-note-font-size` | `1.2rem` | Font size of the `sidebar-note` slot content |
|
|
133
119
|
| `--service-detail-sidebar-note-margin-block-start` | `1.2rem` | Space above the `sidebar-note` slot content |
|
|
134
120
|
| `--service-detail-related-items-gap` | `1rem` | Gap between related-service item cards |
|
|
135
|
-
| `--service-detail-related-item-background` | `var(--
|
|
121
|
+
| `--service-detail-related-item-background` | `var(--theme-surface-subtle)` | Background colour of a related-service item's card |
|
|
136
122
|
| `--service-detail-related-item-padding` | `1rem` | Padding inside a related-service item's card |
|
|
137
|
-
| `--service-detail-related-item-border-colour` | `
|
|
123
|
+
| `--service-detail-related-item-border-colour` | `transparent` | Border colour of a related-service item's card |
|
|
138
124
|
| `--service-detail-related-item-border-radius` | `0.4rem` | Corner rounding of a related-service item's card |
|
|
139
125
|
| `--service-detail-related-item-gap` | `1.2rem` | Gap between a related item's thumbnail and its text |
|
|
140
126
|
| `--service-detail-related-image-size` | `5.6rem` | Width/height of a related-service thumbnail |
|
|
141
127
|
| `--service-detail-related-image-border-radius` | `0.4rem` | Corner rounding of a related-service thumbnail |
|
|
142
128
|
| `--service-detail-related-price-font-size` | `1.2rem` | Font size of a related item's price |
|
|
129
|
+
| `--service-detail-related-title-line-clamp` | `none` | Max lines of a related item's title (default item only, not the `related-service` slot). `1` is single-line ellipsis |
|
|
130
|
+
|
|
131
|
+
> Changed 2026-10-06: the ideal-for and related-service cards defaulted to dark `--slate-09` with a
|
|
132
|
+
> `--slate-06` border but set no text colour, so on a light page their inherited dark text was
|
|
133
|
+
> unreadable. They now default to `var(--theme-surface-subtle)` with a transparent border. To keep
|
|
134
|
+
> dark cards, set the four `-background`/`-border-colour` tokens.
|
|
143
135
|
| `--service-detail-related-price-margin-block-start` | `0.6rem` | Space between a related item's title and its price |
|
|
144
136
|
| `--service-detail-muted-opacity` | `0.7` | Opacity shared by every secondary/muted text element (sidebar label, sidebar note, related price, final CTA body) |
|
|
145
137
|
|
|
@@ -153,6 +145,11 @@ The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also app
|
|
|
153
145
|
| `--service-detail-final-cta-margin-block-start` | `3.2rem` | Space above the banner |
|
|
154
146
|
| `--service-detail-final-cta-padding-block` | `3.2rem 3.2rem` | Padding (block-start block-end) around the banner content, below its divider |
|
|
155
147
|
| `--service-detail-final-cta-divider-colour` | `currentColor` | Divider line above the banner |
|
|
148
|
+
| `--service-detail-final-cta-heading-line-clamp` | `none` | Max lines of the banner heading |
|
|
149
|
+
| `--service-detail-final-cta-body-line-clamp` | `none` | Max lines of the banner body text |
|
|
150
|
+
|
|
151
|
+
The `final-cta` slot sits in `.service-detail__final-cta-action`, which never shrinks: long copy
|
|
152
|
+
wraps beside it instead of pushing the button out of the banner.
|
|
156
153
|
|
|
157
154
|
The banner becomes a single row (copy left, CTA right) at a **700px container width**. Its own
|
|
158
155
|
breakout width is controlled by the `finalCtaVariant` prop (default `"content"`), not a token —
|
|
@@ -160,6 +157,47 @@ see Layout building blocks above.
|
|
|
160
157
|
|
|
161
158
|
---
|
|
162
159
|
|
|
160
|
+
## State hooks
|
|
161
|
+
|
|
162
|
+
No state classes. Inner classes are all `service-detail__*` (hero, hero image/overlay/content, breadcrumb,
|
|
163
|
+
hero pills, body, main, process, ideal-for and its items/icon, faqs and each faq/question, sidebar,
|
|
164
|
+
booking card, sidebar label/rows/note, related items, final CTA). FAQ questions render one heading level
|
|
165
|
+
below `subheadingTag` (`h3` for the default `h2`, `h4` for `h3`).
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Layout building blocks
|
|
170
|
+
|
|
171
|
+
The hero banner is a [GridStack](../../../../../.claude/skills/components/grid-stack.md) with two
|
|
172
|
+
stacked layers — an `image` layer (the service photo) behind a `content` layer (the scrim +
|
|
173
|
+
breadcrumb/title/pills). Each layer's root element is itself a
|
|
174
|
+
[PageRow](../../../../../.claude/skills/components/page-row.md): `.service-detail__hero-image-row`
|
|
175
|
+
(prop `heroImageVariant`, default `"full"`) wraps the photo, and `.service-detail__hero-overlay`
|
|
176
|
+
(prop `heroContentVariant`, default `"content"`) *is* the scrim/alignment element, not a wrapper
|
|
177
|
+
around one — a `PageRow`'s own box always spans the full width of its parent regardless of variant,
|
|
178
|
+
only its children default into the variant's grid column, so the one element can carry the
|
|
179
|
+
full-bleed scrim while its child (`.service-detail__hero-content`) still gets pulled to the
|
|
180
|
+
configured column. This means the photo bleeds to whatever width `.service-detail` itself is allowed
|
|
181
|
+
(edge-to-edge if `ServiceDetail` is placed without a content-constraining wrapper around it), while
|
|
182
|
+
the breadcrumb/title/pills always stay pulled into the page's normal content-width column,
|
|
183
|
+
regardless of how wide the photo bleeds. `--service-detail-hero-padding` now only controls
|
|
184
|
+
**block** (vertical) padding on the scrim — horizontal alignment of the text is handled entirely by
|
|
185
|
+
`.service-detail__hero-overlay`'s own `PageRow` grid, not by padding, so it can't fight the page's
|
|
186
|
+
content-grid gutters.
|
|
187
|
+
|
|
188
|
+
The two-column body (main content + sticky sidebar) is wrapped in its own `PageRow`, whose variant
|
|
189
|
+
is a prop: `bodyVariant` (`"full" | "popout" | "content" | "inset-content"`, default `"content"`).
|
|
190
|
+
The closing full-width CTA banner is wrapped the same way via `finalCtaVariant` (same type, same
|
|
191
|
+
`"content"` default). This means a consumer no longer needs to wrap `ServiceDetail` itself in an
|
|
192
|
+
outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
|
|
193
|
+
directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
|
|
194
|
+
inside an outer, wider `PageRow` — see
|
|
195
|
+
[service-summary.md](../../../../../.claude/skills/components/service-summary.md)-style nesting
|
|
196
|
+
notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
|
|
197
|
+
compose.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
163
201
|
## Text content — props, not CSS
|
|
164
202
|
|
|
165
203
|
Section headings, sidebar labels, and CTA copy are **props**, not hardcoded strings:
|
|
@@ -177,6 +215,14 @@ Section headings, sidebar labels, and CTA copy are **props**, not hardcoded stri
|
|
|
177
215
|
| `relatedServicesHeading` | `"You May Also Like"` |
|
|
178
216
|
| `finalCtaHeading` | `"Ready to book your appointment?"` |
|
|
179
217
|
| `finalCtaBody` | `"Get in touch to book your appointment."` |
|
|
218
|
+
| `pricePrefix` | `"From"` (hero price pill; `""` shows the price alone) |
|
|
219
|
+
| `breadcrumbAriaLabel` | Breadcrumb's own default |
|
|
220
|
+
| `idealForIcon` | `"mdi:diamond-stone"` (decorative, hidden from assistive tech) |
|
|
221
|
+
|
|
222
|
+
A section is left out, heading and all, when its data is empty (`process`, `idealFor`, `faqs`,
|
|
223
|
+
`maintenance`, a `heroHeading` with no text), and so is any heading or label prop set to `""`. Empty
|
|
224
|
+
`subtitle`, `title`, `price`, `duration`, `longDescription`, `whatIsIt` and FAQ questions/answers render
|
|
225
|
+
nothing, and empty breadcrumb labels are dropped.
|
|
180
226
|
|
|
181
227
|
```vue
|
|
182
228
|
<ServiceDetail
|
|
@@ -226,6 +272,12 @@ never reach the hero pills or breadcrumb. Use the `--service-detail-hero-*` toke
|
|
|
226
272
|
|
|
227
273
|
---
|
|
228
274
|
|
|
275
|
+
## Class passthrough
|
|
276
|
+
|
|
277
|
+
`style-class-passthrough` adds classes to the root `.service-detail`. Reactive after mount.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
229
281
|
## Notes
|
|
230
282
|
|
|
231
283
|
- Routing is entirely delegated to the consumer via slots: `book-cta` (scoped `serviceData`),
|
|
@@ -251,4 +303,3 @@ never reach the hero pills or breadcrumb. Use the `--service-detail-hero-*` toke
|
|
|
251
303
|
`@container` queries against the component's own width, not the viewport — they are not tokens
|
|
252
304
|
since resizing them would need corresponding `minmax()`/`grid-template-columns` changes too, not
|
|
253
305
|
just spacing (same rationale as `ServiceSummary`'s `--service-summary-grid-gap-desktop`).
|
|
254
|
-
|