srcdev-nuxt-components 9.3.6 → 9.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/check-component-update.sh +30 -1
- package/.claude/skills/components/accordian-core.md +7 -7
- package/.claude/skills/components/breadcrumb.md +49 -0
- package/.claude/skills/components/content-docs.md +2 -1
- package/.claude/skills/components/expanding-panel.md +5 -5
- package/.claude/skills/components/navigation-items.md +1 -0
- package/.claude/skills/components/responsive-header.md +1 -0
- package/.claude/skills/components/service-detail.md +156 -0
- package/.claude/skills/components/services-card-grid.md +9 -9
- package/.claude/skills/components/services-card.md +82 -30
- package/.claude/skills/index.md +3 -1
- package/.claude/skills/qa-panel.md +2 -2
- package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
- package/.vscode/srcdev-component-service-detail.code-snippets +49 -0
- package/.vscode/srcdev-component-services-card.code-snippets +108 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +22 -16
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
- package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +5 -1
- package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +22 -14
- package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +42 -28
- package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +8 -10
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +15 -47
- package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +4 -12
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +91 -4
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +5 -1
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +2 -2
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +13 -13
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +13 -13
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +181 -0
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -0
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
- package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +19 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +8 -8
- package/app/types/components/breadcrumb.ts +4 -0
- package/app/types/components/index.ts +1 -0
- package/package.json +2 -2
- package/app/pages/auto-grid.vue +0 -328
- package/app/pages/banner-video.vue +0 -238
- package/app/pages/forms/examples/buttons/index.vue +0 -259
- package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
- package/app/pages/forms/examples/material/cssbattle.vue +0 -60
- package/app/pages/forms/examples/material/text-fields.vue +0 -910
- package/app/pages/grid-stack.vue +0 -252
- package/app/pages/index.vue +0 -127
- package/app/pages/page-hero-highlights.vue +0 -204
- package/app/pages/samaritan.vue +0 -327
- package/app/pages/typography/hero-text.vue +0 -97
- package/app/pages/typography/index.vue +0 -39
- package/app/pages/typography/page-body.vue +0 -171
- package/app/pages/typography/page-heading.vue +0 -76
- package/app/pages/typography/page-link.vue +0 -103
- package/app/pages/ui/accordian.vue +0 -211
- package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
- package/app/pages/ui/anchor-scroll.vue +0 -367
- package/app/pages/ui/animated-svg-text.vue +0 -85
- package/app/pages/ui/block-decorators.vue +0 -142
- package/app/pages/ui/card-core.vue +0 -82
- package/app/pages/ui/carousel-basic.vue +0 -292
- package/app/pages/ui/carousel-flip.vue +0 -449
- package/app/pages/ui/carousel-infinite.vue +0 -260
- package/app/pages/ui/clipped-panels.vue +0 -101
- package/app/pages/ui/contact-section.vue +0 -323
- package/app/pages/ui/container-glow.vue +0 -106
- package/app/pages/ui/content-width.vue +0 -126
- package/app/pages/ui/deep-expanding-menu.vue +0 -138
- package/app/pages/ui/display-avatar.vue +0 -253
- package/app/pages/ui/display-banner.vue +0 -78
- package/app/pages/ui/display-chip.vue +0 -367
- package/app/pages/ui/display-details.vue +0 -171
- package/app/pages/ui/display-dialog.vue +0 -344
- package/app/pages/ui/display-pill.vue +0 -396
- package/app/pages/ui/display-prompt.vue +0 -284
- package/app/pages/ui/display-toast.vue +0 -311
- package/app/pages/ui/expanding-panel.vue +0 -326
- package/app/pages/ui/glowing-border.vue +0 -111
- package/app/pages/ui/layout-content-docs.vue +0 -72
- package/app/pages/ui/layout-grid-a.vue +0 -96
- package/app/pages/ui/layout-grid-b.vue +0 -132
- package/app/pages/ui/magnetic-navigation.vue +0 -50
- package/app/pages/ui/marquee-scroller.vue +0 -99
- package/app/pages/ui/mask-element.vue +0 -104
- package/app/pages/ui/masonry-columns.vue +0 -55
- package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
- package/app/pages/ui/masonry-grid-ordered.vue +0 -56
- package/app/pages/ui/masonry-grid-sorted.vue +0 -55
- package/app/pages/ui/masonry-grid.vue +0 -55
- package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
- package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
- package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
- package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
- package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
- package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
- package/app/pages/ui/navigation/tab-navigation.vue +0 -493
- package/app/pages/ui/page-row.vue +0 -127
- package/app/pages/ui/price-list.vue +0 -65
- package/app/pages/ui/profile-section.vue +0 -142
- package/app/pages/ui/qr-code/[componentName].vue +0 -218
- package/app/pages/ui/rotating-carousel.vue +0 -154
- package/app/pages/ui/scroll-reveal-image.vue +0 -483
- package/app/pages/ui/section-parallax.vue +0 -54
- package/app/pages/ui/services/colour-finder.vue +0 -43
- package/app/pages/ui/services/services-cards.vue +0 -57
- package/app/pages/ui/services/services-section/[slug].vue +0 -61
- package/app/pages/ui/services/services-sections-compact.vue +0 -64
- package/app/pages/ui/settings.vue +0 -54
- package/app/pages/ui/simple-grid.vue +0 -55
- package/app/pages/ui/slider-gallery.vue +0 -261
- package/app/pages/ui/tabs-y.vue +0 -107
- package/app/pages/ui/tabs.vue +0 -189
- package/app/pages/ui/tooltips.vue +0 -104
- package/app/pages/ui/wipe-away-vertical.vue +0 -129
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# ServicesCard — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
Every token below is consumed directly at its point of use (`var(--services-card-gap, 1rem)`,
|
|
6
|
+
etc.) — there is no `--_`-prefixed private indirection layer, since none of these values are
|
|
7
|
+
reused across multiple declarations or computed from something else. Set the token globally in
|
|
8
|
+
a theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough` — no
|
|
9
|
+
`:deep()` needed, component styles are unscoped.
|
|
10
|
+
|
|
11
|
+
### Interaction states (whole-card-clickable only)
|
|
12
|
+
|
|
13
|
+
Only visible when the card is in whole-card-clickable mode (see the component skill doc) —
|
|
14
|
+
i.e. `is-clickable` is on the root — since these all default to `transparent`/`0` and only
|
|
15
|
+
`.is-clickable` has a `:hover`/`:focus-visible` rule at all.
|
|
16
|
+
|
|
17
|
+
| Token | Default | Controls |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `--services-card-border-radius` | `0` | Root corner radius — also clips `.image-wrapper` (root has `overflow: hidden`), so set `--image-wrapper-border-radius` to match if you want the image's own corners rounded consistently with a rounded root |
|
|
20
|
+
| `--services-card-border-width` | `1px` | Border thickness |
|
|
21
|
+
| `--services-card-border-colour` | `transparent` | Resting (non-hover) border colour |
|
|
22
|
+
| `--services-card-border-colour-hover` | resting border colour | Border colour on `:hover`/`:focus-visible` — defaults to whatever `--services-card-border-colour` resolves to, so setting only the resting colour keeps the border visible on hover instead of it vanishing |
|
|
23
|
+
| `--services-card-outline-width` | `2px` | Outline thickness |
|
|
24
|
+
| `--services-card-outline-colour` | `transparent` | Resting (non-hover) outline colour |
|
|
25
|
+
| `--services-card-outline-colour-hover` | `transparent` | Outline colour on `:hover`/`:focus-visible` |
|
|
26
|
+
| `--services-card-outline-offset` | `0px` | Resting outline offset |
|
|
27
|
+
| `--services-card-outline-offset-hover` | `0px` | Outline offset on `:hover`/`:focus-visible` — e.g. set to a larger value than the resting offset for an outline that grows outward on hover |
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
/* A card that gets a visible border + outline ring when it's a whole-card link */
|
|
31
|
+
.services-card {
|
|
32
|
+
--services-card-border-colour: var(--theme-border);
|
|
33
|
+
--services-card-outline-colour-hover: var(--theme-border-focus);
|
|
34
|
+
--services-card-outline-offset-hover: 4px;
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Layout & sizing
|
|
39
|
+
|
|
40
|
+
| Token | Default | Controls |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `--services-card-gap` | `1rem` | Gap between the image row and the details row |
|
|
43
|
+
| `--image-wrapper-aspect-ratio` | `3/4` | Image aspect ratio |
|
|
44
|
+
| `--image-wrapper-border-radius` | `8px` | Image corner radius |
|
|
45
|
+
| `--image-wrapper-padding-block` | `0 0` | Padding inside the image wrapper (block axis) |
|
|
46
|
+
| `--image-wrapper-padding-inline` | `0 0` | Padding inside the image wrapper (inline axis) |
|
|
47
|
+
| `--image-wrapper-border-image-zoom-transform` | `scale(1.05)` | Transform applied to the image on hover |
|
|
48
|
+
| `--details-wrapper-grid-gap` | `1rem` | Gap between eyebrow/title/description/footer |
|
|
49
|
+
| `--details-wrapper-padding-block` | `0` | Padding inside the details wrapper (block axis) |
|
|
50
|
+
| `--details-wrapper-padding-inline` | `0` | Padding inside the details wrapper (inline axis) |
|
|
51
|
+
| `--eyebrow-text-padding-block` | `0.8rem 0` | Padding above/below the eyebrow |
|
|
52
|
+
| `--hero-text-padding-block` | `2rem 1rem` | Padding above/below the title |
|
|
53
|
+
| `--description-padding-block` | `0 0` | Padding on the description text |
|
|
54
|
+
| `--description-text-colour` | `var(--colour-text-secondary)` | Description paragraph colour |
|
|
55
|
+
| `--description-line-height` | `1.4` | Description line height |
|
|
56
|
+
| `--description-line-clamp` | `100` | Number of lines the description clamps to — default is effectively unclamped; set e.g. `3` to clamp |
|
|
57
|
+
| `--meta-padding-block` | `1.6rem 0` | Padding above/below the meta row's divider line |
|
|
58
|
+
| `--meta-border-colour` | `var(--theme-border)` | Meta row's top divider colour |
|
|
59
|
+
| `--meta-text-colour` | `inherit` | Duration/price text colour |
|
|
60
|
+
| `--meta-font-size` | `1.4rem` | Duration/price text size |
|
|
61
|
+
| `--meta-text-transform` | `uppercase` | Text transform on the duration/price meta row |
|
|
62
|
+
| `--footer-padding-block` | `0` | Padding on the `.footer` wrapper (meta row + actions slot) |
|
|
63
|
+
| `--footer-wrapper-grid-gap` | `1rem` | Gap between the meta row and the actions slot inside `.footer` |
|
|
64
|
+
|
|
65
|
+
```css
|
|
66
|
+
/* assets/styles/setup/07.components/services-card.css */
|
|
67
|
+
:root {
|
|
68
|
+
--services-card-gap: 1.4rem;
|
|
69
|
+
--description-line-clamp: 3;
|
|
70
|
+
--meta-text-transform: none;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Text content — props/slots, not CSS
|
|
77
|
+
|
|
78
|
+
Duration and price text come from `serviceData.duration`/`serviceData.price` by default, or
|
|
79
|
+
`durationText`/`priceText` props, or the `duration`/`price` scoped slots (for icons or other
|
|
80
|
+
custom markup) — there is nothing to override in CSS for the text itself, only its styling via
|
|
81
|
+
the tokens above. See the component skill doc for slot/prop usage examples.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Per-instance overrides via styleClassPassthrough
|
|
86
|
+
|
|
87
|
+
Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct look:
|
|
88
|
+
|
|
89
|
+
```vue
|
|
90
|
+
<ServicesCard :style-class-passthrough="['featured-service']" :service-data="service" />
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```css
|
|
94
|
+
.services-card.featured-service {
|
|
95
|
+
--services-card-gap: 1.6rem;
|
|
96
|
+
--meta-border-colour: var(--color-accent);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Footer bottom-alignment (row-of-cards use)
|
|
103
|
+
|
|
104
|
+
`.footer` (the meta row + `actions` slot, grouped together) gets `margin-block-start: auto`
|
|
105
|
+
and the root uses `grid-template-rows: auto 1fr`, so when `ServicesCardGrid`'s default
|
|
106
|
+
`align-items: stretch` makes a card taller than its own content (to match a taller sibling in
|
|
107
|
+
the same row), the extra height goes to `.footer`'s top margin instead of leaving whitespace
|
|
108
|
+
below the actions slot. This keeps the meta row and actions/button aligned across a row of
|
|
109
|
+
cards regardless of each card's description length, without needing CSS subgrid.
|
|
110
|
+
|
|
111
|
+
`.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set
|
|
112
|
+
`min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot
|
|
113
|
+
button label, since `InputButtonCore`'s `.button-text` is `white-space: nowrap` with no
|
|
114
|
+
ellipsis — forces its content's min-content width up through the flex/grid chain and widens
|
|
115
|
+
that one card's grid column in `ServicesCardGrid` wider than its siblings. If a card/column
|
|
116
|
+
ever looks wider than the rest with content cut off at the edge, check for a long unbreakable
|
|
117
|
+
string in a slot before assuming it's an image sizing issue — the image is just riding along
|
|
118
|
+
on the widened column, not the cause.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Notes
|
|
123
|
+
|
|
124
|
+
- `--details-wrapper-grid-gap` (details-wrapper's own children) and `--footer-wrapper-grid-gap`
|
|
125
|
+
(the meta row ↔ actions slot gap inside `.footer`) are separate tokens — overriding one does
|
|
126
|
+
not affect the other.
|
|
127
|
+
- `.meta`'s internal gap (between the duration and price text) is a fixed `1rem`, not a token.
|
|
128
|
+
- Root element tag, `href`/`external` (whole-card-clickable), `eyebrowConfig`, and `heroConfig`
|
|
129
|
+
are props, not CSS — see the component skill doc.
|
|
@@ -1,23 +1,44 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<component
|
|
2
|
+
<component
|
|
3
|
+
:is="resolvedTag"
|
|
4
|
+
:href="isClickable ? props.href : undefined"
|
|
5
|
+
class="services-card"
|
|
6
|
+
:class="[elementClasses, { 'is-clickable': isClickable }]"
|
|
7
|
+
>
|
|
3
8
|
<div class="image-wrapper">
|
|
4
9
|
<NuxtImg :src="serviceData.image" :alt="serviceData.title" loading="lazy" class="image" />
|
|
5
10
|
</div>
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
<div class="details-wrapper">
|
|
12
|
+
<EyebrowText
|
|
13
|
+
:font-size="eyebrowConfig.fontSize ?? 'large'"
|
|
14
|
+
:tag="eyebrowConfig.tag ?? 'div'"
|
|
15
|
+
:text-content="serviceData.subtitle"
|
|
16
|
+
/>
|
|
17
|
+
<HeroText
|
|
18
|
+
:tag="heroConfig.tag ?? 'h2'"
|
|
19
|
+
:font-size="heroConfig.fontSize ?? 'heading'"
|
|
20
|
+
:text-content="[
|
|
21
|
+
{
|
|
22
|
+
text: serviceData.title,
|
|
23
|
+
styleClass: 'normal',
|
|
24
|
+
},
|
|
25
|
+
]"
|
|
26
|
+
/>
|
|
27
|
+
<div class="description">
|
|
28
|
+
{{ serviceData.shortDescription }}
|
|
29
|
+
</div>
|
|
30
|
+
<div v-if="hasFooter" class="footer">
|
|
31
|
+
<div v-if="hasMeta" class="meta">
|
|
32
|
+
<div class="meta-duration">
|
|
33
|
+
<slot name="duration" :service-data="serviceData">{{ durationText }}</slot>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="meta-price">
|
|
36
|
+
<slot name="price" :service-data="serviceData">{{ priceText }}</slot>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<slot name="actions" :service-data="serviceData"></slot>
|
|
40
|
+
</div>
|
|
19
41
|
</div>
|
|
20
|
-
<slot name="actions" :service-data="serviceData"></slot>
|
|
21
42
|
</component>
|
|
22
43
|
</template>
|
|
23
44
|
|
|
@@ -37,18 +58,53 @@ interface HeroConfig {
|
|
|
37
58
|
interface Props {
|
|
38
59
|
tag?: "div" | "section" | "article";
|
|
39
60
|
serviceData: Service;
|
|
61
|
+
href?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Force a real browser navigation instead of client-side routing, even for a same-origin
|
|
64
|
+
* href starting with "/". See InputButtonCore's `external` prop for the full rationale.
|
|
65
|
+
*/
|
|
66
|
+
external?: boolean;
|
|
40
67
|
eyebrowConfig?: EyebrowConfig;
|
|
41
68
|
heroConfig?: HeroConfig;
|
|
69
|
+
/** Overrides serviceData.duration. Ignored if the `duration` slot is used. */
|
|
70
|
+
durationText?: string;
|
|
71
|
+
/** Overrides serviceData.price. Ignored if the `price` slot is used. */
|
|
72
|
+
priceText?: string;
|
|
42
73
|
styleClassPassthrough?: string | string[];
|
|
43
74
|
}
|
|
44
75
|
|
|
45
76
|
const props = withDefaults(defineProps<Props>(), {
|
|
46
77
|
tag: "div",
|
|
78
|
+
href: undefined,
|
|
79
|
+
external: false,
|
|
47
80
|
eyebrowConfig: () => ({}),
|
|
48
81
|
heroConfig: () => ({}),
|
|
82
|
+
durationText: undefined,
|
|
83
|
+
priceText: undefined,
|
|
49
84
|
styleClassPassthrough: () => [],
|
|
50
85
|
});
|
|
51
86
|
|
|
87
|
+
const slots = useSlots();
|
|
88
|
+
const NuxtLink = resolveComponent("NuxtLink");
|
|
89
|
+
|
|
90
|
+
const durationText = computed(() => props.durationText ?? props.serviceData.duration);
|
|
91
|
+
const priceText = computed(() => props.priceText ?? props.serviceData.price);
|
|
92
|
+
const hasMeta = computed(() => Boolean(slots.duration || slots.price || durationText.value || priceText.value));
|
|
93
|
+
// Groups the meta row and actions slot so they can be pushed to the bottom of the card as
|
|
94
|
+
// one unit — keeps them aligned across a row of cards in ServicesCardGrid regardless of how
|
|
95
|
+
// long each card's description is, without needing per-row CSS subgrid across components.
|
|
96
|
+
const hasFooter = computed(() => Boolean(hasMeta.value || slots.actions));
|
|
97
|
+
|
|
98
|
+
// Whole card is clickable only when there's no actions slot to hold its own interactive
|
|
99
|
+
// content (which would otherwise end up nested inside the card's own anchor) and an href is set
|
|
100
|
+
const isClickable = computed(() => Boolean(!slots.actions && props.href));
|
|
101
|
+
const isInternalLink = computed(() => isClickable.value && props.href!.startsWith("/") && !props.external);
|
|
102
|
+
const resolvedTag = computed(() => {
|
|
103
|
+
if (!isClickable.value) return props.tag;
|
|
104
|
+
if (isInternalLink.value) return NuxtLink;
|
|
105
|
+
return "a";
|
|
106
|
+
});
|
|
107
|
+
|
|
52
108
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
53
109
|
|
|
54
110
|
watch(
|
|
@@ -62,19 +118,44 @@ watch(
|
|
|
62
118
|
<style lang="css">
|
|
63
119
|
@layer components {
|
|
64
120
|
.services-card {
|
|
65
|
-
/* Consumer definable css tokens */
|
|
66
|
-
--_eyebrow-text-margin-block: 0.8rem 0;
|
|
67
|
-
--_hero-text-margin-block: 2rem 1rem;
|
|
68
|
-
--_description-text-colour: var(--colour-text-secondary);
|
|
69
|
-
|
|
70
121
|
display: grid;
|
|
71
|
-
grid-template-rows: auto
|
|
72
|
-
gap: 1rem;
|
|
122
|
+
grid-template-rows: auto 1fr;
|
|
123
|
+
gap: var(--services-card-gap, 1rem);
|
|
124
|
+
min-inline-size: 0;
|
|
125
|
+
|
|
126
|
+
border-radius: var(--services-card-border-radius, 0);
|
|
127
|
+
border: var(--services-card-border-width, 1px) solid var(--services-card-border-colour, transparent);
|
|
128
|
+
outline: var(--services-card-outline-width, 2px) solid var(--services-card-outline-colour, transparent);
|
|
129
|
+
outline-offset: var(--services-card-outline-offset, 0px);
|
|
130
|
+
overflow: hidden;
|
|
131
|
+
|
|
132
|
+
transition:
|
|
133
|
+
border-color 0.3s ease-in-out,
|
|
134
|
+
outline-color 0.3s ease-in-out,
|
|
135
|
+
outline-offset 0.3s ease-in-out;
|
|
136
|
+
|
|
137
|
+
&.is-clickable {
|
|
138
|
+
color: inherit;
|
|
139
|
+
text-decoration: none;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
|
|
142
|
+
&:hover,
|
|
143
|
+
&:focus-visible {
|
|
144
|
+
/* Falls back to the resting border colour (not transparent) so setting only
|
|
145
|
+
--services-card-border-colour doesn't make the border vanish on hover/focus. */
|
|
146
|
+
border-color: var(--services-card-border-colour-hover, var(--services-card-border-colour, transparent));
|
|
147
|
+
outline-color: var(--services-card-outline-colour-hover, transparent);
|
|
148
|
+
outline-offset: var(--services-card-outline-offset-hover, 0px);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
73
151
|
|
|
74
152
|
.image-wrapper {
|
|
75
|
-
aspect-ratio: 3/4;
|
|
76
|
-
border-radius: 8px;
|
|
153
|
+
aspect-ratio: var(--image-wrapper-aspect-ratio, 3/4);
|
|
154
|
+
border-radius: var(--image-wrapper-border-radius, 8px);
|
|
77
155
|
overflow: hidden;
|
|
156
|
+
min-inline-size: 0;
|
|
157
|
+
padding-block: var(--image-wrapper-padding-block, 0 0);
|
|
158
|
+
padding-inline: var(--image-wrapper-padding-inline, 0 0);
|
|
78
159
|
|
|
79
160
|
.image {
|
|
80
161
|
display: block;
|
|
@@ -84,28 +165,61 @@ watch(
|
|
|
84
165
|
transition: transform 0.3s ease-in-out;
|
|
85
166
|
|
|
86
167
|
&:hover {
|
|
87
|
-
transform: scale(1.
|
|
168
|
+
transform: var(--image-wrapper-border-image-zoom-transform, scale(1.05));
|
|
88
169
|
}
|
|
89
170
|
}
|
|
90
171
|
}
|
|
91
172
|
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
173
|
+
.details-wrapper {
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
gap: var(--details-wrapper-grid-gap, 1rem);
|
|
177
|
+
min-inline-size: 0;
|
|
95
178
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
179
|
+
padding-block: var(--details-wrapper-padding-block, 0);
|
|
180
|
+
padding-inline: var(--details-wrapper-padding-inline, 0);
|
|
99
181
|
|
|
100
|
-
|
|
101
|
-
|
|
182
|
+
.eyebrow-text {
|
|
183
|
+
padding-block: var(--eyebrow-text-padding-block, 0.8rem 0);
|
|
184
|
+
}
|
|
102
185
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
186
|
+
.hero-text {
|
|
187
|
+
padding-block: var(--hero-text-padding-block, 2rem 1rem);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.description {
|
|
191
|
+
color: var(--description-text-colour, var(--colour-text-secondary));
|
|
192
|
+
line-height: var(--description-line-height, 1.4);
|
|
193
|
+
padding-block: var(--description-padding-block, 0 0);
|
|
194
|
+
|
|
195
|
+
display: -webkit-box;
|
|
196
|
+
-webkit-box-orient: vertical;
|
|
197
|
+
overflow: hidden;
|
|
198
|
+
-webkit-line-clamp: var(--description-line-clamp, 100);
|
|
199
|
+
line-clamp: var(--description-line-clamp, 100);
|
|
200
|
+
text-overflow: ellipsis;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.footer {
|
|
204
|
+
display: flex;
|
|
205
|
+
flex-direction: column;
|
|
206
|
+
gap: var(--footer-wrapper-grid-gap, 1rem);
|
|
207
|
+
margin-block-start: auto;
|
|
208
|
+
min-inline-size: 0;
|
|
209
|
+
padding-block: var(--footer-padding-block, 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.meta {
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: center;
|
|
215
|
+
justify-content: space-between;
|
|
216
|
+
gap: 1rem;
|
|
217
|
+
padding-block: var(--meta-padding-block, 1.6rem 0);
|
|
218
|
+
border-block-start: 1px solid var(--meta-border-colour, var(--theme-border));
|
|
219
|
+
color: var(--meta-text-colour, inherit);
|
|
220
|
+
font-size: var(--meta-font-size, 1.4rem);
|
|
221
|
+
text-transform: var(--meta-text-transform, uppercase);
|
|
222
|
+
}
|
|
109
223
|
}
|
|
110
224
|
}
|
|
111
225
|
}
|
|
@@ -19,6 +19,22 @@ const meta: Meta<typeof ServicesCard> = {
|
|
|
19
19
|
control: "object",
|
|
20
20
|
description: "Override hero tag and fontSize — omit keys to use defaults",
|
|
21
21
|
},
|
|
22
|
+
href: {
|
|
23
|
+
control: "text",
|
|
24
|
+
description: "When set and no actions slot is provided, the whole card becomes a link",
|
|
25
|
+
},
|
|
26
|
+
external: {
|
|
27
|
+
control: "boolean",
|
|
28
|
+
description: "Force a plain <a> instead of NuxtLink for an internal-looking href",
|
|
29
|
+
},
|
|
30
|
+
durationText: {
|
|
31
|
+
control: "text",
|
|
32
|
+
description: "Overrides serviceData.duration in the meta row",
|
|
33
|
+
},
|
|
34
|
+
priceText: {
|
|
35
|
+
control: "text",
|
|
36
|
+
description: "Overrides serviceData.price in the meta row",
|
|
37
|
+
},
|
|
22
38
|
styleClassPassthrough: {
|
|
23
39
|
control: "object",
|
|
24
40
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -31,6 +47,12 @@ const meta: Meta<typeof ServicesCard> = {
|
|
|
31
47
|
styleClassPassthrough: [],
|
|
32
48
|
},
|
|
33
49
|
parameters: {
|
|
50
|
+
backgrounds: {
|
|
51
|
+
default: "storybook-canvas",
|
|
52
|
+
options: {
|
|
53
|
+
"storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
34
56
|
docs: {
|
|
35
57
|
description: {
|
|
36
58
|
component:
|
|
@@ -66,6 +88,26 @@ const sampleService: Service = {
|
|
|
66
88
|
seoDescription: "Professional locs installation service.",
|
|
67
89
|
};
|
|
68
90
|
|
|
91
|
+
const balayageService: Service = {
|
|
92
|
+
slug: "balayage",
|
|
93
|
+
category: "hair",
|
|
94
|
+
title: "Balayage",
|
|
95
|
+
subtitle: "Freehand Colour Artistry",
|
|
96
|
+
price: "From £95",
|
|
97
|
+
duration: "2.5 - 3.5 hours",
|
|
98
|
+
image: "https://picsum.photos/seed/balayage-card/600/800",
|
|
99
|
+
shortDescription: "Colour swept on by hand, bespoke to your hair's natural fall and texture.",
|
|
100
|
+
longDescription: "Full description not used in card view.",
|
|
101
|
+
heroHeading: [{ text: "Why choose balayage?", styleClass: "normal" }],
|
|
102
|
+
whatIsIt: "A freehand colour technique for a natural, sun-kissed result.",
|
|
103
|
+
process: ["Consultation", "Freehand application", "Toner", "Finish & style"],
|
|
104
|
+
idealFor: ["Low-maintenance colour", "Natural-looking dimension"],
|
|
105
|
+
maintenance: "Refresh every 3–4 months.",
|
|
106
|
+
faqs: [{ question: "Does balayage damage hair?", answer: "No more than any other lightening service when done correctly." }],
|
|
107
|
+
seoTitle: "Balayage",
|
|
108
|
+
seoDescription: "Freehand balayage colour service.",
|
|
109
|
+
};
|
|
110
|
+
|
|
69
111
|
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
70
112
|
|
|
71
113
|
export const WithActions: Story = {
|
|
@@ -192,3 +234,26 @@ export const AsArticle: Story = {
|
|
|
192
234
|
},
|
|
193
235
|
},
|
|
194
236
|
};
|
|
237
|
+
|
|
238
|
+
export const DesignReplica: Story = {
|
|
239
|
+
name: "Design Replica — Dark Card With Meta Row",
|
|
240
|
+
render: (args) => ({
|
|
241
|
+
components: { ServicesCard },
|
|
242
|
+
setup() {
|
|
243
|
+
return { args, balayageService };
|
|
244
|
+
},
|
|
245
|
+
template: `
|
|
246
|
+
<div style="color-scheme:dark; background:#141414; max-width:360px; padding:2.4rem;">
|
|
247
|
+
<ServicesCard v-bind="args" :service-data="balayageService" />
|
|
248
|
+
</div>
|
|
249
|
+
`,
|
|
250
|
+
}),
|
|
251
|
+
parameters: {
|
|
252
|
+
docs: {
|
|
253
|
+
description: {
|
|
254
|
+
story:
|
|
255
|
+
"Reproduces the reference design: dark background, no actions slot, and the duration/price meta row sourced from serviceData.duration/serviceData.price (uppercased via CSS).",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
@@ -108,6 +108,44 @@ describe("ServicesCard", () => {
|
|
|
108
108
|
expect(wrapper.find(".image-wrapper img").attributes("loading")).toBe("lazy");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
// ─── Meta row (duration / price) ───────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
it("renders serviceData.duration and serviceData.price by default", async () => {
|
|
114
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
115
|
+
props: { serviceData: mockService },
|
|
116
|
+
});
|
|
117
|
+
expect(wrapper.find(".meta-duration").text()).toBe(mockService.duration);
|
|
118
|
+
expect(wrapper.find(".meta-price").text()).toBe(mockService.price);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("overrides duration/price text with durationText/priceText props", async () => {
|
|
122
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
123
|
+
props: { serviceData: mockService, durationText: "1 hour", priceText: "£75" },
|
|
124
|
+
});
|
|
125
|
+
expect(wrapper.find(".meta-duration").text()).toBe("1 hour");
|
|
126
|
+
expect(wrapper.find(".meta-price").text()).toBe("£75");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("overrides duration/price content with duration/price slots", async () => {
|
|
130
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
131
|
+
props: { serviceData: mockService },
|
|
132
|
+
slots: {
|
|
133
|
+
duration: '<span class="custom-duration">Quick</span>',
|
|
134
|
+
price: '<span class="custom-price">Free</span>',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
expect(wrapper.find(".custom-duration").text()).toBe("Quick");
|
|
138
|
+
expect(wrapper.find(".custom-price").text()).toBe("Free");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("hides the meta row when there is no duration, price, or slot content", async () => {
|
|
142
|
+
const serviceWithoutMeta: Service = { ...mockService, duration: "", price: "" };
|
|
143
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
144
|
+
props: { serviceData: serviceWithoutMeta },
|
|
145
|
+
});
|
|
146
|
+
expect(wrapper.find(".meta").exists()).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
111
149
|
// ─── Actions slot ───────────────────────────────────────────────────────
|
|
112
150
|
|
|
113
151
|
it("renders the actions slot", async () => {
|
|
@@ -128,6 +166,50 @@ describe("ServicesCard", () => {
|
|
|
128
166
|
expect(wrapper.vm).toBeTruthy();
|
|
129
167
|
});
|
|
130
168
|
|
|
169
|
+
// ─── Whole-card clickable (href, no actions slot) ──────────────────────
|
|
170
|
+
|
|
171
|
+
it("stays as the tag prop when href is not set", async () => {
|
|
172
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
173
|
+
props: { serviceData: mockService },
|
|
174
|
+
});
|
|
175
|
+
expect(wrapper.element.tagName).toBe("DIV");
|
|
176
|
+
expect(wrapper.attributes("href")).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("stays as the tag prop when href is set but an actions slot is provided", async () => {
|
|
180
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
181
|
+
props: { serviceData: mockService, href: "/services/test-service" },
|
|
182
|
+
slots: { actions: '<button class="test-cta">Book now</button>' },
|
|
183
|
+
});
|
|
184
|
+
expect(wrapper.element.tagName).toBe("DIV");
|
|
185
|
+
expect(wrapper.attributes("href")).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("renders as an anchor with href when href is set and no actions slot is provided", async () => {
|
|
189
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
190
|
+
props: { serviceData: mockService, href: "https://example.com/book" },
|
|
191
|
+
});
|
|
192
|
+
expect(wrapper.element.tagName).toBe("A");
|
|
193
|
+
expect(wrapper.attributes("href")).toBe("https://example.com/book");
|
|
194
|
+
expect(wrapper.classes()).toContain("is-clickable");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("renders as NuxtLink for an internal href", async () => {
|
|
198
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
199
|
+
props: { serviceData: mockService, href: "/services/test-service" },
|
|
200
|
+
});
|
|
201
|
+
expect(wrapper.element.tagName).toBe("A");
|
|
202
|
+
expect(wrapper.attributes("href")).toBe("/services/test-service");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("renders as a plain anchor for an internal href when external is true", async () => {
|
|
206
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
207
|
+
props: { serviceData: mockService, href: "/api/auth/github", external: true },
|
|
208
|
+
});
|
|
209
|
+
expect(wrapper.element.tagName).toBe("A");
|
|
210
|
+
expect(wrapper.attributes("href")).toBe("/api/auth/github");
|
|
211
|
+
});
|
|
212
|
+
|
|
131
213
|
// ─── eyebrowConfig ──────────────────────────────────────────────────────
|
|
132
214
|
|
|
133
215
|
it("uses default eyebrow fontSize of large", async () => {
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
exports[`ServicesCard > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="services-card">
|
|
5
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="
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
<div class="details-wrapper">
|
|
7
|
+
<div class="eyebrow-text large">A subtitle</div>
|
|
8
|
+
<h2 class="hero-text heading axis-horizontal">
|
|
9
|
+
<!--v-if--><span class="text-block-0 normal">Test Service</span>
|
|
10
|
+
</h2>
|
|
11
|
+
<div class="description">A short description of the service.</div>
|
|
12
|
+
<div class="footer">
|
|
13
|
+
<div class="meta">
|
|
14
|
+
<div class="meta-duration">60 mins</div>
|
|
15
|
+
<div class="meta-price">£50</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
11
19
|
</div>"
|
|
12
20
|
`;
|
|
@@ -50,7 +50,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
50
50
|
tag: "div",
|
|
51
51
|
eyebrowConfig: () => ({}),
|
|
52
52
|
heroConfig: () => ({}),
|
|
53
|
-
hrefBase: "/
|
|
53
|
+
hrefBase: "/services/",
|
|
54
54
|
buttonTextPrefix: "Enquire about",
|
|
55
55
|
styleClassPassthrough: () => [],
|
|
56
56
|
});
|
|
@@ -68,13 +68,9 @@ watch(
|
|
|
68
68
|
<style lang="css">
|
|
69
69
|
@layer components {
|
|
70
70
|
.services-card-grid {
|
|
71
|
-
/* Consumer definable css tokens */
|
|
72
|
-
--_gap: 4rem;
|
|
73
|
-
--_column-min-width: 250px;
|
|
74
|
-
|
|
75
71
|
display: grid;
|
|
76
|
-
grid-template-columns: repeat(auto-fit, minmax(var(--
|
|
77
|
-
gap: var(--
|
|
72
|
+
grid-template-columns: repeat(auto-fit, minmax(var(--services-card-grid-column-min-width, 250px), 1fr));
|
|
73
|
+
gap: var(--services-card-grid-gap, 4rem);
|
|
78
74
|
}
|
|
79
75
|
}
|
|
80
76
|
</style>
|