srcdev-nuxt-components 9.5.2 → 9.5.3
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/canvas-switcher.md +5 -1
- package/.claude/skills/components/eyebrow-text.md +6 -4
- package/.claude/skills/components/services-card.md +25 -2
- package/.claude/skills/storybook-add-story.md +5 -0
- package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
- package/.vscode/srcdev-component-services-card.code-snippets +38 -7
- package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
- package/app/composables/tests/useAriaDescribedById.spec.ts +5 -5
- package/package.json +1 -1
|
@@ -47,7 +47,11 @@ const canvasName = ref<MediaCanvas>("desktopCanvas");
|
|
|
47
47
|
The selected value doubles as a utility class name: `CanvasSwitcher`'s stylesheet also declares
|
|
48
48
|
`.mobileCanvas`/`.tabletCanvas`/`.laptopCanvas`/`.desktopCanvas`/`.fullWidthCanvas` (max-width
|
|
49
49
|
utilities, not scoped to `.canvas-switcher`), so binding `:class="canvasName"` directly onto a
|
|
50
|
-
preview wrapper constrains it to match
|
|
50
|
+
preview wrapper constrains it to match. `DashboardQuadGrid`/`DashboardStatsGrid` stories use the
|
|
51
|
+
classes alone (driven by a Controls select); `PageRow.stories.ts` renders the switcher itself in a
|
|
52
|
+
meta-level decorator, so every story gets an in-canvas width toggle. Copy that decorator for any
|
|
53
|
+
component that sizes itself from its container (`%`, container queries) rather than from
|
|
54
|
+
`@media`: a constrained wrapper cannot trigger a viewport media query.
|
|
51
55
|
These classes only ship in the bundle once `CanvasSwitcher` (or another importer of them) is
|
|
52
56
|
actually imported somewhere, since Nuxt only bundles a component's SFC styles when it's used.
|
|
53
57
|
|
|
@@ -71,9 +71,10 @@ reference: `app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md
|
|
|
71
71
|
|
|
72
72
|
## Styling
|
|
73
73
|
|
|
74
|
-
Key CSS custom properties:
|
|
74
|
+
Key CSS custom properties (full list in `CONSUMER-STYLING.md`):
|
|
75
75
|
|
|
76
|
-
- `--
|
|
76
|
+
- `--eyebrow-text-bg-img` — text fill (gradient via `background-clip: text`; use a single-colour gradient for a solid colour)
|
|
77
|
+
- `--eyebrow-text-font-family`, `--eyebrow-text-font-style` (default `italic`), `--eyebrow-text-font-weight`, `--eyebrow-text-letter-spacing` — typography
|
|
77
78
|
|
|
78
79
|
Text is always `text-transform: uppercase` — do not pass pre-uppercased strings, as this makes content harder to edit and search.
|
|
79
80
|
|
|
@@ -95,8 +96,9 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
95
96
|
─────────────────────────────────────────────────────────────────── */
|
|
96
97
|
.eyebrow-text {
|
|
97
98
|
&.my-eyebrow {
|
|
98
|
-
/*
|
|
99
|
-
/* --
|
|
99
|
+
/* --eyebrow-text-bg-img: linear-gradient(var(--brand-accent), var(--brand-accent)); */
|
|
100
|
+
/* --eyebrow-text-font-style: normal; */
|
|
101
|
+
/* --eyebrow-text-letter-spacing: 0.2em; */
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
</style>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| `tag` | `"div" \| "section" \| "article"` | `"div"` | no |
|
|
13
13
|
| `href` | `string` | `undefined` | no |
|
|
14
14
|
| `external` | `boolean` | `false` | no |
|
|
15
|
+
| `titlesWithinImageWrapper` | `boolean` | `false` | no |
|
|
15
16
|
| `eyebrowConfig` | `ServicesCardEyebrowConfig` | `{}` | no |
|
|
16
17
|
| `heroConfig` | `ServicesCardHeroConfig` | `{}` | no |
|
|
17
18
|
| `durationText` | `string` | `undefined` | no |
|
|
@@ -30,6 +31,17 @@ If an `actions` slot is provided, the card stays as a static `tag` element (no h
|
|
|
30
31
|
|
|
31
32
|
`is-clickable` is added to the root class list when whole-card-clickable mode is active, giving `cursor: pointer` and removing default link colour/underline. It's also what gates the border/outline hover/focus interaction-state tokens — see "Interaction states (whole-card-clickable only)" in `CONSUMER-STYLING.md`; they're a no-op when the card isn't clickable, since only `.is-clickable` has a `:hover`/`:focus-visible` rule.
|
|
32
33
|
|
|
34
|
+
### Titles over the image (`titlesWithinImageWrapper`)
|
|
35
|
+
|
|
36
|
+
When `true`, the eyebrow and title move out of `.details-wrapper` and sit over the bottom of the image instead. The image wrapper is a `GridStack`: the image is layer 1, and `.image-wrapper-details` (eyebrow + title) is layer 2. The description, meta row and actions stay below the image.
|
|
37
|
+
|
|
38
|
+
- A gradient scrim behind the text keeps it readable. Its colour comes from `--image-wrapper-details-scrim-colour` (default black), and `--image-wrapper-details-scrim` replaces the whole background (`none` removes it).
|
|
39
|
+
- Text colour uses `contrast-color()` against the scrim colour where supported (black or white, whichever reads better), falling back to white. It reads the scrim colour only, not the photo, so if you remove the scrim, set `--image-wrapper-details-text-colour` yourself.
|
|
40
|
+
- The eyebrow's gradient fill is turned off over the image; it takes the text colour (or `--image-wrapper-details-eyebrow-text-colour`).
|
|
41
|
+
- The eyebrow's default `fontSize` is `"medium"` in this mode (vs `"large"` below the image). `eyebrowConfig`/`heroConfig` still apply.
|
|
42
|
+
|
|
43
|
+
See "Titles over the image" in `CONSUMER-STYLING.md` for the token list.
|
|
44
|
+
|
|
33
45
|
### EyebrowConfig
|
|
34
46
|
|
|
35
47
|
Exported as `ServicesCardEyebrowConfig` (and `ServicesCardHeroConfig` below) from `srcdev-nuxt-components` since 2026-09-28.
|
|
@@ -37,7 +49,7 @@ Exported as `ServicesCardEyebrowConfig` (and `ServicesCardHeroConfig` below) fro
|
|
|
37
49
|
| Key | Type | Default |
|
|
38
50
|
| ---------- | ---------------------------------- | --------- |
|
|
39
51
|
| `tag` | `"p" \| "div" \| "span"` | `"div"` |
|
|
40
|
-
| `fontSize` | `"large" \| "medium" \| "small"` | `"large"` |
|
|
52
|
+
| `fontSize` | `"large" \| "medium" \| "small"` | `"large"` (`"medium"` with `titlesWithinImageWrapper`) |
|
|
41
53
|
|
|
42
54
|
### HeroConfig
|
|
43
55
|
|
|
@@ -159,6 +171,16 @@ See `CONSUMER-STYLING.md` for the full token list — every token is a plain pub
|
|
|
159
171
|
consumed directly at its point of use (no `--_`-prefixed private indirection layer), so all of
|
|
160
172
|
them work both globally (`:root`) and scoped like the example above.
|
|
161
173
|
|
|
174
|
+
## Titles over the image usage
|
|
175
|
+
|
|
176
|
+
```vue
|
|
177
|
+
<ServicesCard :service-data="service" titles-within-image-wrapper>
|
|
178
|
+
<template #actions="{ serviceData }">
|
|
179
|
+
<InputButton variant="secondary" button-text="Enquire" :href="`/services/${serviceData.slug}`" />
|
|
180
|
+
</template>
|
|
181
|
+
</ServicesCard>
|
|
182
|
+
```
|
|
183
|
+
|
|
162
184
|
## Whole-card clickable usage
|
|
163
185
|
|
|
164
186
|
```vue
|
|
@@ -173,6 +195,7 @@ No `actions` slot — the entire card renders as an anchor (`NuxtLink` for the i
|
|
|
173
195
|
- The `Service` type is imported from `~/types/types.services`.
|
|
174
196
|
- Root markup is two rows (`grid-template-rows: auto 1fr`): `.image-wrapper`, then a `.details-wrapper` (`display: flex; flex-direction: column`) holding the eyebrow, title, description, and a `.footer` wrapper. Description length is controlled by `--description-line-clamp` (default effectively unclamped) rather than a fixed-height grid row.
|
|
175
197
|
- `.footer` groups the meta row and the `actions` slot and gets `margin-block-start: auto`, pinning them to the bottom of the card. Combined with the root's `1fr` details row, this means when `ServicesCardGrid`'s default `align-items: stretch` makes a card taller than its own content (to match a taller sibling in the same row), the extra height goes to `.footer`'s top margin rather than leaving whitespace below the actions slot — so the meta row and actions/button line up across a row of cards regardless of each card's description length. This does the visual job of CSS subgrid without needing a subgrid chain across `ServicesCard`/`ServicesCardGrid` (which would also break whenever cards in a row don't all render the same optional rows — `.meta` and `actions` are both conditional).
|
|
176
|
-
- Image has a `3/4` aspect ratio with a subtle scale-on-hover effect.
|
|
198
|
+
- Image has a `3/4` aspect ratio with a subtle scale-on-hover effect, triggered by hovering the image only (not the whole card). When the card is a whole-card link styled with its own hover effect (e.g. `--services-card-transform-hover`), set `--image-wrapper-border-image-zoom-transform: none` so the two effects don't stack; the "Dark Overlay Cards" story does this. Keep the zoom for the `actions`-slot layout, where the card itself has no hover state. The hover is on `.image-wrapper`, not the image, so it still fires when the overlaid titles cover the image. `.image-wrapper-details` is `position: relative; z-index: 1` because the image's hover `transform` would otherwise paint over it, and `.image-wrapper` sets `isolation: isolate` so that z-index stays inside the card.
|
|
177
199
|
- `.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, since `InputButton`'s `.button-text` is `white-space: nowrap` with no ellipsis — forces its content's min-content width up through the flex/grid chain and widens that one card's grid column in `ServicesCardGrid` wider than its siblings (the image just rides along on the widened column; it isn't the actual cause). If you see one card/column wider than the rest with cut-off content, check for a long unbreakable string in a slot before assuming it's an image sizing issue.
|
|
178
200
|
- Usually consumed via `ServicesCardGrid` rather than directly.
|
|
201
|
+
- The "Dark Overlay Cards" story shows a full restyle using tokens only: dark panel, flush image, titles over the image, hover lift (`--services-card-transform-hover`), and EyebrowText typography tokens (`--eyebrow-text-font-style`, `-letter-spacing`, etc.) set on the card so they inherit into its eyebrow.
|
|
@@ -277,6 +277,11 @@ Key points:
|
|
|
277
277
|
(`html[data-color-scheme="light"]`, off-white `--slate-00` stage) by default, whatever the OS
|
|
278
278
|
setting. Use the toolbar "Colour scheme" menu (Light / Dark / Follow OS) to check dark mode.
|
|
279
279
|
Don't hardcode a stage background in a story to get a light canvas; it's already the default.
|
|
280
|
+
- **Edge-aware components need `layout: "fullscreen"`** in the meta's `parameters`. Storybook's
|
|
281
|
+
default `"padded"` layout insets the canvas by 1rem, which masks viewport gutters, full-bleed
|
|
282
|
+
tracks and edge alignment (`PageRow`, `SliderGallery`). To swap canvas widths inside a story,
|
|
283
|
+
add the `CanvasSwitcher` decorator from `PageRow.stories.ts` (container-sized components only;
|
|
284
|
+
see `components/canvas-switcher.md`).
|
|
280
285
|
- Use `table: { category: "..." }` in `argTypes` when a component has many props — it groups
|
|
281
286
|
them in the Storybook controls panel (e.g. `"Model"`, `"Basic"`, `"Validation"`, `"Styling"`, `"Slots"`).
|
|
282
287
|
- Export multiple named stories (`Default`, `WithError`, `Outlined`, etc.) when you want
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
".$1my-page {",
|
|
14
14
|
" --eyebrow-text-bg-img: linear-gradient(135deg, $2, $3);",
|
|
15
15
|
" --eyebrow-text-medium: $4;",
|
|
16
|
+
" --eyebrow-text-font-family: ;",
|
|
17
|
+
" --eyebrow-text-font-style: ;",
|
|
18
|
+
" --eyebrow-text-font-weight: ;",
|
|
19
|
+
" --eyebrow-text-letter-spacing: ;",
|
|
16
20
|
"}"
|
|
17
21
|
]
|
|
18
22
|
}
|
|
@@ -25,6 +25,17 @@
|
|
|
25
25
|
"<ServicesCard :service-data=\"$1service\" :href=\"`/services/${$1service.slug}`\" />"
|
|
26
26
|
]
|
|
27
27
|
},
|
|
28
|
+
"SRCDEV ServicesCard Titles Over Image": {
|
|
29
|
+
"description": "ServicesCard with eyebrow and title overlaid on the image, over a scrim",
|
|
30
|
+
"scope": "vue,html",
|
|
31
|
+
"body": [
|
|
32
|
+
"<ServicesCard :service-data=\"$1service\" titles-within-image-wrapper>",
|
|
33
|
+
" <template #actions=\"{ serviceData }\">",
|
|
34
|
+
" <InputButton variant=\"secondary\" button-text=\"$2Enquire\" :href=\"`/services/${serviceData.slug}`\" />",
|
|
35
|
+
" </template>",
|
|
36
|
+
"</ServicesCard>"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
28
39
|
"SRCDEV ServicesCard With Config Overrides": {
|
|
29
40
|
"description": "ServicesCard with eyebrowConfig/heroConfig overrides",
|
|
30
41
|
"scope": "vue,html",
|
|
@@ -76,8 +87,13 @@
|
|
|
76
87
|
" --services-card-outline-colour-hover: ;",
|
|
77
88
|
" --services-card-outline-offset: ;",
|
|
78
89
|
" --services-card-outline-offset-hover: ;",
|
|
90
|
+
" --services-card-background-color-hover: ;",
|
|
91
|
+
" --services-card-transform-hover: ;",
|
|
79
92
|
"",
|
|
80
93
|
" --services-card-gap: ;",
|
|
94
|
+
" --services-card-background-color: ;",
|
|
95
|
+
" --services-card-padding-block: ;",
|
|
96
|
+
" --services-card-padding-inline: ;",
|
|
81
97
|
" --image-wrapper-aspect-ratio: ;",
|
|
82
98
|
" --image-wrapper-border-radius: ;",
|
|
83
99
|
" --image-wrapper-padding-block: ;",
|
|
@@ -86,21 +102,36 @@
|
|
|
86
102
|
" --details-wrapper-grid-gap: ;",
|
|
87
103
|
" --details-wrapper-padding-block: ;",
|
|
88
104
|
" --details-wrapper-padding-inline: ;",
|
|
105
|
+
" --eyebrow-text-padding-block: ;",
|
|
106
|
+
" --hero-text-padding-block: ;",
|
|
107
|
+
" --description-text-colour: ;",
|
|
89
108
|
" --description-padding-block: ;",
|
|
90
109
|
" --description-line-height: ;",
|
|
110
|
+
" --description-font-weight: ;",
|
|
91
111
|
" --description-line-clamp: ;",
|
|
92
112
|
" --meta-padding-block: ;",
|
|
113
|
+
" --meta-border-colour: ;",
|
|
114
|
+
" --meta-text-colour: ;",
|
|
115
|
+
" --meta-duration-text-colour: ;",
|
|
116
|
+
" --meta-price-text-colour: ;",
|
|
117
|
+
" --meta-duration-font-weight: ;",
|
|
118
|
+
" --meta-price-font-weight: ;",
|
|
119
|
+
" --meta-letter-spacing: ;",
|
|
120
|
+
" --meta-font-size: ;",
|
|
93
121
|
" --meta-text-transform: ;",
|
|
94
122
|
" --footer-padding-block: ;",
|
|
95
123
|
" --footer-wrapper-grid-gap: ;",
|
|
96
124
|
"",
|
|
97
|
-
" /*
|
|
98
|
-
" --
|
|
99
|
-
" --
|
|
100
|
-
" --
|
|
101
|
-
" --
|
|
102
|
-
" --
|
|
103
|
-
" --
|
|
125
|
+
" /* Titles over the image (titlesWithinImageWrapper) */",
|
|
126
|
+
" --image-wrapper-details-scrim-colour: ;",
|
|
127
|
+
" --image-wrapper-details-scrim: ;",
|
|
128
|
+
" --image-wrapper-details-text-colour: ;",
|
|
129
|
+
" --image-wrapper-details-eyebrow-text-colour: ;",
|
|
130
|
+
" --image-wrapper-details-gap: ;",
|
|
131
|
+
" --image-wrapper-details-eyebrow-text-padding-block: ;",
|
|
132
|
+
" --image-wrapper-details-eyebrow-text-padding-inline: ;",
|
|
133
|
+
" --image-wrapper-details-hero-text-padding-block: ;",
|
|
134
|
+
" --image-wrapper-details-hero-text-padding-inline: ;",
|
|
104
135
|
" }",
|
|
105
136
|
"}"
|
|
106
137
|
]
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import { ref } from "vue";
|
|
2
3
|
import PageRowComponent from "../PageRow.vue";
|
|
4
|
+
import CanvasSwitcher from "../../canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { MediaCanvas } from "~/types/components";
|
|
3
6
|
|
|
4
7
|
interface PageRowArgs {
|
|
5
8
|
tag: string;
|
|
@@ -47,7 +50,27 @@ export default {
|
|
|
47
50
|
id: "",
|
|
48
51
|
styleClassPassthrough: [],
|
|
49
52
|
},
|
|
53
|
+
decorators: [
|
|
54
|
+
// PageRow sizes its tracks from 100% of its container, so a constrained wrapper stands in for a narrower viewport.
|
|
55
|
+
(story) => ({
|
|
56
|
+
components: { story, CanvasSwitcher },
|
|
57
|
+
setup() {
|
|
58
|
+
const canvasName = ref<MediaCanvas>("fullWidthCanvas");
|
|
59
|
+
return { canvasName };
|
|
60
|
+
},
|
|
61
|
+
template: `
|
|
62
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
63
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
64
|
+
</div>
|
|
65
|
+
<div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
|
|
66
|
+
<story />
|
|
67
|
+
</div>
|
|
68
|
+
`,
|
|
69
|
+
}),
|
|
70
|
+
],
|
|
50
71
|
parameters: {
|
|
72
|
+
// Storybook's default padded layout hides the viewport gutter and align edge-bleed.
|
|
73
|
+
layout: "fullscreen",
|
|
51
74
|
docs: {
|
|
52
75
|
description: {
|
|
53
76
|
component:
|
|
@@ -118,26 +141,37 @@ InsetContent.parameters = {
|
|
|
118
141
|
const NestingTemplate: StoryFn = () => ({
|
|
119
142
|
components: { PageRowComponent },
|
|
120
143
|
setup() {
|
|
121
|
-
|
|
144
|
+
const nested = [
|
|
145
|
+
{ variant: "full", background: "#fca5a5", desc: "edge to edge" },
|
|
146
|
+
{ variant: "popout", background: "#fdba74", desc: "max 1400px" },
|
|
147
|
+
{ variant: "content", background: "#fde047", desc: "max 1064px" },
|
|
148
|
+
{ variant: "inset-content", background: "#86efac", desc: "max 840px" },
|
|
149
|
+
];
|
|
150
|
+
const chip =
|
|
151
|
+
"display: inline-block; margin-block-end: 8px; padding: 2px 8px; border-radius: 4px; background: rgba(0,0,0,0.75); color: white; font-family: monospace; font-size: 1.2rem;";
|
|
152
|
+
return { sampleText, nested, chip };
|
|
122
153
|
},
|
|
123
154
|
template: `
|
|
124
155
|
<div style="min-height: 100vh;">
|
|
125
156
|
|
|
126
|
-
<PageRowComponent tag="section"
|
|
157
|
+
<PageRowComponent tag="section" style="background: #1e3a5f; color: white; padding-block: 60px;">
|
|
158
|
+
<span :style="chip">outer PageRow (variant="content")</span>
|
|
127
159
|
<h1 style="margin: 0 0 16px; font-size: 2.4rem;">Full-width background, constrained content</h1>
|
|
128
160
|
<p style="margin: 0; opacity: 0.85; line-height: 1.7;">{{ sampleText }}</p>
|
|
129
161
|
</PageRowComponent>
|
|
130
162
|
|
|
131
|
-
<PageRowComponent tag="section" style="background: #
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
163
|
+
<PageRowComponent tag="section" style="background: #0f172a; padding-block: 48px; display: grid; row-gap: 16px;">
|
|
164
|
+
<span :style="chip" style="background: white; color: #0f172a;">outer PageRow (dark background)</span>
|
|
165
|
+
<PageRowComponent
|
|
166
|
+
v-for="row in nested"
|
|
167
|
+
:key="row.variant"
|
|
168
|
+
:variant="row.variant"
|
|
169
|
+
:style="{ background: row.background, color: '#111827', paddingBlock: '24px' }"
|
|
170
|
+
>
|
|
171
|
+
<div>
|
|
172
|
+
<span :style="chip">nested variant="{{ row.variant }}"</span>
|
|
173
|
+
<p style="margin: 0; line-height: 1.6;">{{ row.desc }}: the background shows the track this nested PageRow occupies.</p>
|
|
174
|
+
</div>
|
|
141
175
|
</PageRowComponent>
|
|
142
176
|
</PageRowComponent>
|
|
143
177
|
|
|
@@ -152,7 +186,7 @@ NestingPattern.parameters = {
|
|
|
152
186
|
docs: {
|
|
153
187
|
description: {
|
|
154
188
|
story:
|
|
155
|
-
"The key composition pattern:
|
|
189
|
+
"The key composition pattern: an outer PageRow carries the full-width background, and nested PageRows pick a track via variant. Each nested row's background marks the track it occupies against the dark outer row. No extra wrapper divs needed.",
|
|
156
190
|
},
|
|
157
191
|
},
|
|
158
192
|
};
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
| `--eyebrow-text-large` | `1.4rem` | Font size for `font-size="large"` |
|
|
9
9
|
| `--eyebrow-text-medium` | `1.2rem` | Font size for `font-size="medium"` (default) |
|
|
10
10
|
| `--eyebrow-text-small` | `1rem` | Font size for `font-size="small"` |
|
|
11
|
+
| `--eyebrow-text-font-family` | `inherit` | Font family |
|
|
12
|
+
| `--eyebrow-text-font-style` | `italic` | Font style, e.g. `normal` for upright text |
|
|
13
|
+
| `--eyebrow-text-font-weight` | `inherit` | Font weight |
|
|
14
|
+
| `--eyebrow-text-letter-spacing` | `inherit` | Letter-spacing, e.g. `0.25em` for wide tracking |
|
|
11
15
|
|
|
12
|
-
The library theme also sets
|
|
16
|
+
The library theme also sets the colour and size tokens on `:root` (`03.theming/_default.css` and
|
|
13
17
|
`05.typography/01.tokens/_reponsive-font-sizes.css`) with the same values.
|
|
14
18
|
|
|
15
19
|
> Changed 2026-09-27: the component now carries those values as fallbacks too. Before, an app that
|
|
@@ -31,7 +31,10 @@ watch(
|
|
|
31
31
|
text-transform: uppercase;
|
|
32
32
|
background-clip: text;
|
|
33
33
|
background-image: var(--eyebrow-text-bg-img, linear-gradient(135deg, #c2a770, #b4747e, #d1bd94));
|
|
34
|
-
font-
|
|
34
|
+
font-family: var(--eyebrow-text-font-family, inherit);
|
|
35
|
+
font-style: var(--eyebrow-text-font-style, italic);
|
|
36
|
+
font-weight: var(--eyebrow-text-font-weight, inherit);
|
|
37
|
+
letter-spacing: var(--eyebrow-text-letter-spacing, inherit);
|
|
35
38
|
color: transparent;
|
|
36
39
|
|
|
37
40
|
&.large {
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
## Public token API
|
|
4
4
|
|
|
5
5
|
Every token below is consumed directly at its point of use (`var(--services-card-gap, 1rem)`,
|
|
6
|
-
etc.)
|
|
7
|
-
|
|
6
|
+
etc.). The only private token is `--_scrim-colour`, which resolves
|
|
7
|
+
`--image-wrapper-details-scrim-colour` once so the scrim and the text colour read the same value. Set the token globally in
|
|
8
8
|
a theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough` — no
|
|
9
9
|
`:deep()` needed, component styles are unscoped.
|
|
10
10
|
|
|
@@ -25,6 +25,8 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
|
|
|
25
25
|
| `--services-card-outline-colour-hover` | `transparent` | Outline colour on `:hover`/`:focus-visible` |
|
|
26
26
|
| `--services-card-outline-offset` | `0px` | Resting outline offset |
|
|
27
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
|
+
| `--services-card-background-color-hover` | resting background colour | Background on `:hover`/`:focus-visible` |
|
|
29
|
+
| `--services-card-transform-hover` | `none` | Transform on `:hover`/`:focus-visible`, e.g. `translateY(-0.4rem)` for a lift. Transitions with the other states; skipped under `prefers-reduced-motion: reduce` |
|
|
28
30
|
|
|
29
31
|
```css
|
|
30
32
|
/* A card that gets a visible border + outline ring when it's a whole-card link */
|
|
@@ -40,11 +42,14 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
|
|
|
40
42
|
| Token | Default | Controls |
|
|
41
43
|
|---|---|---|
|
|
42
44
|
| `--services-card-gap` | `1rem` | Gap between the image row and the details row |
|
|
45
|
+
| `--services-card-background-color` | `transparent` | Card background |
|
|
46
|
+
| `--services-card-padding-block` | none | Padding on the card root (block axis) |
|
|
47
|
+
| `--services-card-padding-inline` | `0` | Padding on the card root (inline axis). Leave at `0` for a flush image, and pad `--details-wrapper-padding-*` instead |
|
|
43
48
|
| `--image-wrapper-aspect-ratio` | `3/4` | Image aspect ratio |
|
|
44
49
|
| `--image-wrapper-border-radius` | `8px` | Image corner radius |
|
|
45
50
|
| `--image-wrapper-padding-block` | `0 0` | Padding inside the image wrapper (block axis) |
|
|
46
51
|
| `--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
|
|
52
|
+
| `--image-wrapper-border-image-zoom-transform` | `scale(1.05)` | Transform applied to the image while the pointer is over the image. Set `none` to turn the zoom off (not `scale(0)`, which hides the image). Turning it off suits a whole-card link with its own hover effect (`--services-card-transform-hover`, border/background hover tokens): otherwise moving from the card body onto the image starts a second, separate effect |
|
|
48
53
|
| `--details-wrapper-grid-gap` | `1rem` | Gap between eyebrow/title/description/footer |
|
|
49
54
|
| `--details-wrapper-padding-block` | `0` | Padding inside the details wrapper (block axis) |
|
|
50
55
|
| `--details-wrapper-padding-inline` | `0` | Padding inside the details wrapper (inline axis) |
|
|
@@ -53,15 +58,35 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
|
|
|
53
58
|
| `--description-padding-block` | `0 0` | Padding on the description text |
|
|
54
59
|
| `--description-text-colour` | `var(--colour-text-secondary)` | Description paragraph colour |
|
|
55
60
|
| `--description-line-height` | `1.4` | Description line height |
|
|
61
|
+
| `--description-font-weight` | `inherit` | Description font weight |
|
|
56
62
|
| `--description-line-clamp` | `100` | Number of lines the description clamps to — default is effectively unclamped; set e.g. `3` to clamp |
|
|
57
63
|
| `--meta-padding-block` | `1.6rem 0` | Padding above/below the meta row's divider line |
|
|
58
64
|
| `--meta-border-colour` | `var(--theme-border)` | Meta row's top divider colour |
|
|
59
65
|
| `--meta-text-colour` | `inherit` | Duration/price text colour |
|
|
66
|
+
| `--meta-duration-text-colour` | meta text colour | Duration colour only |
|
|
67
|
+
| `--meta-price-text-colour` | meta text colour | Price colour only, e.g. brighter than the duration |
|
|
68
|
+
| `--meta-duration-font-weight` | `500` | Duration font weight |
|
|
69
|
+
| `--meta-price-font-weight` | `700` | Price font weight |
|
|
70
|
+
| `--meta-letter-spacing` | `inherit` | Letter-spacing on the meta row |
|
|
60
71
|
| `--meta-font-size` | `1.4rem` | Duration/price text size |
|
|
61
72
|
| `--meta-text-transform` | `uppercase` | Text transform on the duration/price meta row |
|
|
62
73
|
| `--footer-padding-block` | `0` | Padding on the `.footer` wrapper (meta row + actions slot) |
|
|
63
74
|
| `--footer-wrapper-grid-gap` | `1rem` | Gap between the meta row and the actions slot inside `.footer` |
|
|
64
75
|
|
|
76
|
+
### Titles over the image (`titlesWithinImageWrapper`)
|
|
77
|
+
|
|
78
|
+
| Token | Default | Controls |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `--image-wrapper-details-scrim-colour` | `#000` | Colour of the gradient scrim behind the title/eyebrow. Use an opaque colour: the default scrim fades it with `color-mix()`, and the text colour is derived from it |
|
|
81
|
+
| `--image-wrapper-details-scrim` | `linear-gradient(to top, <scrim colour at 75%>, transparent 70%)` | The whole scrim `background` value — replace it for a different shape or direction, or set `none` to remove it |
|
|
82
|
+
| `--image-wrapper-details-text-colour` | `contrast-color(<scrim colour>)`, `#fff` without support | Title/eyebrow text colour. Where `contrast-color()` is supported it picks black or white against the scrim colour, so a light scrim gets dark text automatically |
|
|
83
|
+
| `--image-wrapper-details-eyebrow-text-colour` | `inherit` | Eyebrow colour. The eyebrow's gradient fill is turned off over the image so it can't clash with the photo |
|
|
84
|
+
| `--image-wrapper-details-gap` | `0.5rem` | Gap between eyebrow and title |
|
|
85
|
+
| `--image-wrapper-details-eyebrow-text-padding-block` / `-inline` | `0` / `2.2rem` | Eyebrow padding |
|
|
86
|
+
| `--image-wrapper-details-hero-text-padding-block` / `-inline` | `1.2rem 2.2rem` / `2.2rem` | Title padding |
|
|
87
|
+
|
|
88
|
+
`contrast-color()` only reads the scrim colour, not the photo. If you remove the scrim, set `--image-wrapper-details-text-colour` yourself to suit the images.
|
|
89
|
+
|
|
65
90
|
```css
|
|
66
91
|
/* assets/styles/setup/07.components/services-card.css */
|
|
67
92
|
:root {
|
|
@@ -5,16 +5,40 @@
|
|
|
5
5
|
class="services-card"
|
|
6
6
|
:class="[elementClasses, { 'is-clickable': isClickable() }]"
|
|
7
7
|
>
|
|
8
|
-
<div class="image-wrapper">
|
|
9
|
-
<
|
|
10
|
-
|
|
8
|
+
<GridStack tag="div" :style-class-passthrough="['image-wrapper']">
|
|
9
|
+
<template #layer-1>
|
|
10
|
+
<NuxtImg :src="serviceData.image" :alt="serviceData.title" loading="lazy" class="image" />
|
|
11
|
+
</template>
|
|
12
|
+
<template #layer-2>
|
|
13
|
+
<div v-if="props.titlesWithinImageWrapper" class="image-wrapper-details">
|
|
14
|
+
<EyebrowText
|
|
15
|
+
:font-size="eyebrowConfig.fontSize ?? 'medium'"
|
|
16
|
+
:tag="eyebrowConfig.tag ?? 'div'"
|
|
17
|
+
:text-content="serviceData.subtitle"
|
|
18
|
+
/>
|
|
19
|
+
<HeroText
|
|
20
|
+
:tag="heroConfig.tag ?? 'h2'"
|
|
21
|
+
:font-size="heroConfig.fontSize ?? 'heading'"
|
|
22
|
+
:text-content="[
|
|
23
|
+
{
|
|
24
|
+
text: serviceData.title,
|
|
25
|
+
styleClass: 'normal',
|
|
26
|
+
},
|
|
27
|
+
]"
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
</GridStack>
|
|
32
|
+
|
|
11
33
|
<div class="details-wrapper">
|
|
12
34
|
<EyebrowText
|
|
35
|
+
v-if="!props.titlesWithinImageWrapper"
|
|
13
36
|
:font-size="eyebrowConfig.fontSize ?? 'large'"
|
|
14
37
|
:tag="eyebrowConfig.tag ?? 'div'"
|
|
15
38
|
:text-content="serviceData.subtitle"
|
|
16
39
|
/>
|
|
17
40
|
<HeroText
|
|
41
|
+
v-if="!props.titlesWithinImageWrapper"
|
|
18
42
|
:tag="heroConfig.tag ?? 'h2'"
|
|
19
43
|
:font-size="heroConfig.fontSize ?? 'heading'"
|
|
20
44
|
:text-content="[
|
|
@@ -50,6 +74,7 @@ interface Props {
|
|
|
50
74
|
tag?: "div" | "section" | "article";
|
|
51
75
|
serviceData: Service;
|
|
52
76
|
href?: string;
|
|
77
|
+
titlesWithinImageWrapper?: boolean;
|
|
53
78
|
/**
|
|
54
79
|
* Force a real browser navigation instead of client-side routing, even for a same-origin
|
|
55
80
|
* href starting with "/". See InputButton's `external` prop for the full rationale.
|
|
@@ -67,6 +92,7 @@ interface Props {
|
|
|
67
92
|
const props = withDefaults(defineProps<Props>(), {
|
|
68
93
|
tag: "div",
|
|
69
94
|
href: undefined,
|
|
95
|
+
titlesWithinImageWrapper: false,
|
|
70
96
|
external: false,
|
|
71
97
|
eyebrowConfig: () => ({}),
|
|
72
98
|
heroConfig: () => ({}),
|
|
@@ -114,6 +140,9 @@ watch(
|
|
|
114
140
|
gap: var(--services-card-gap, 1rem);
|
|
115
141
|
min-inline-size: 0;
|
|
116
142
|
|
|
143
|
+
background-color: var(--services-card-background-color, transparent);
|
|
144
|
+
padding-block: var(--services-card-padding-block,);
|
|
145
|
+
padding-inline: var(--services-card-padding-inline, 0);
|
|
117
146
|
border-radius: var(--services-card-border-radius, 0);
|
|
118
147
|
border: var(--services-card-border-width, 1px) solid var(--services-card-border-colour, transparent);
|
|
119
148
|
outline: var(--services-card-outline-width, 2px) solid var(--services-card-outline-colour, transparent);
|
|
@@ -121,9 +150,11 @@ watch(
|
|
|
121
150
|
overflow: hidden;
|
|
122
151
|
|
|
123
152
|
transition:
|
|
153
|
+
background-color 0.3s ease-in-out,
|
|
124
154
|
border-color 0.3s ease-in-out,
|
|
125
155
|
outline-color 0.3s ease-in-out,
|
|
126
|
-
outline-offset 0.3s ease-in-out
|
|
156
|
+
outline-offset 0.3s ease-in-out,
|
|
157
|
+
transform 0.3s ease-in-out;
|
|
127
158
|
|
|
128
159
|
&.is-clickable {
|
|
129
160
|
color: inherit;
|
|
@@ -134,9 +165,21 @@ watch(
|
|
|
134
165
|
&:focus-visible {
|
|
135
166
|
/* Falls back to the resting border colour (not transparent) so setting only
|
|
136
167
|
--services-card-border-colour doesn't make the border vanish on hover/focus. */
|
|
168
|
+
background-color: var(
|
|
169
|
+
--services-card-background-color-hover,
|
|
170
|
+
var(--services-card-background-color, transparent)
|
|
171
|
+
);
|
|
137
172
|
border-color: var(--services-card-border-colour-hover, var(--services-card-border-colour, transparent));
|
|
138
173
|
outline-color: var(--services-card-outline-colour-hover, transparent);
|
|
139
174
|
outline-offset: var(--services-card-outline-offset-hover, 0px);
|
|
175
|
+
transform: var(--services-card-transform-hover, none);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@media (prefers-reduced-motion: reduce) {
|
|
179
|
+
&:hover,
|
|
180
|
+
&:focus-visible {
|
|
181
|
+
transform: none;
|
|
182
|
+
}
|
|
140
183
|
}
|
|
141
184
|
}
|
|
142
185
|
|
|
@@ -144,6 +187,7 @@ watch(
|
|
|
144
187
|
aspect-ratio: var(--image-wrapper-aspect-ratio, 3/4);
|
|
145
188
|
border-radius: var(--image-wrapper-border-radius, 8px);
|
|
146
189
|
overflow: hidden;
|
|
190
|
+
isolation: isolate;
|
|
147
191
|
min-inline-size: 0;
|
|
148
192
|
padding-block: var(--image-wrapper-padding-block, 0 0);
|
|
149
193
|
padding-inline: var(--image-wrapper-padding-inline, 0 0);
|
|
@@ -154,9 +198,46 @@ watch(
|
|
|
154
198
|
width: 100%;
|
|
155
199
|
height: 100%;
|
|
156
200
|
transition: transform 0.3s ease-in-out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
&:hover .image {
|
|
204
|
+
transform: var(--image-wrapper-border-image-zoom-transform, scale(1.05));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.image-wrapper-details {
|
|
208
|
+
--_scrim-colour: var(--image-wrapper-details-scrim-colour, #000);
|
|
209
|
+
|
|
210
|
+
position: relative;
|
|
211
|
+
z-index: 1;
|
|
212
|
+
display: grid;
|
|
213
|
+
grid-auto-flow: row;
|
|
214
|
+
align-content: end;
|
|
215
|
+
gap: var(--image-wrapper-details-gap, 0.5rem);
|
|
216
|
+
height: 100%;
|
|
217
|
+
color: var(--image-wrapper-details-text-colour, #fff);
|
|
218
|
+
background: var(--image-wrapper-details-scrim, linear-gradient(to top, rgb(0 0 0 / 0.75), transparent 70%));
|
|
157
219
|
|
|
158
|
-
|
|
159
|
-
|
|
220
|
+
@supports (color: color-mix(in oklab, red, transparent)) {
|
|
221
|
+
background: var(
|
|
222
|
+
--image-wrapper-details-scrim,
|
|
223
|
+
linear-gradient(to top, color-mix(in oklab, var(--_scrim-colour) 75%, transparent), transparent 70%)
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@supports (color: contrast-color(red)) {
|
|
228
|
+
color: var(--image-wrapper-details-text-colour, contrast-color(var(--_scrim-colour)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.eyebrow-text {
|
|
232
|
+
--eyebrow-text-bg-img: none;
|
|
233
|
+
color: var(--image-wrapper-details-eyebrow-text-colour, inherit);
|
|
234
|
+
padding-block: var(--image-wrapper-details-eyebrow-text-padding-block, 0);
|
|
235
|
+
padding-inline: var(--image-wrapper-details-eyebrow-text-padding-inline, 2.2rem);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.hero-text {
|
|
239
|
+
padding-block: var(--image-wrapper-details-hero-text-padding-block, 1.2rem 2.2rem);
|
|
240
|
+
padding-inline: var(--image-wrapper-details-hero-text-padding-inline, 2.2rem);
|
|
160
241
|
}
|
|
161
242
|
}
|
|
162
243
|
}
|
|
@@ -175,11 +256,12 @@ watch(
|
|
|
175
256
|
}
|
|
176
257
|
|
|
177
258
|
.hero-text {
|
|
178
|
-
padding-block: var(--hero-text-padding-block, 2rem 1rem);
|
|
259
|
+
padding-block: var(--hero-text-padding-block, 1.2rem 1rem);
|
|
179
260
|
}
|
|
180
261
|
|
|
181
262
|
.description {
|
|
182
263
|
color: var(--description-text-colour, var(--colour-text-secondary));
|
|
264
|
+
font-weight: var(--description-font-weight, inherit);
|
|
183
265
|
line-height: var(--description-line-height, 1.4);
|
|
184
266
|
padding-block: var(--description-padding-block, 0 0);
|
|
185
267
|
|
|
@@ -198,18 +280,29 @@ watch(
|
|
|
198
280
|
margin-block-start: auto;
|
|
199
281
|
min-inline-size: 0;
|
|
200
282
|
padding-block: var(--footer-padding-block, 0);
|
|
201
|
-
}
|
|
202
283
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
284
|
+
.meta {
|
|
285
|
+
display: flex;
|
|
286
|
+
align-items: center;
|
|
287
|
+
justify-content: space-between;
|
|
288
|
+
gap: 1rem;
|
|
289
|
+
padding-block: var(--meta-padding-block, 1.6rem 0);
|
|
290
|
+
border-block-start: 1px solid var(--meta-border-colour, var(--theme-border));
|
|
291
|
+
color: var(--meta-text-colour, inherit);
|
|
292
|
+
font-size: var(--meta-font-size, 1.4rem);
|
|
293
|
+
letter-spacing: var(--meta-letter-spacing, inherit);
|
|
294
|
+
text-transform: var(--meta-text-transform, uppercase);
|
|
295
|
+
|
|
296
|
+
.meta-duration {
|
|
297
|
+
color: var(--meta-duration-text-colour, inherit);
|
|
298
|
+
font-weight: var(--meta-duration-font-weight, 500);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.meta-price {
|
|
302
|
+
color: var(--meta-price-text-colour, inherit);
|
|
303
|
+
font-weight: var(--meta-price-font-weight, 700);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
213
306
|
}
|
|
214
307
|
}
|
|
215
308
|
}
|
|
@@ -257,3 +257,125 @@ export const DesignReplica: Story = {
|
|
|
257
257
|
},
|
|
258
258
|
},
|
|
259
259
|
};
|
|
260
|
+
|
|
261
|
+
const makeOverlayService = (
|
|
262
|
+
slug: string,
|
|
263
|
+
subtitle: string,
|
|
264
|
+
title: string,
|
|
265
|
+
shortDescription: string,
|
|
266
|
+
duration: string,
|
|
267
|
+
price: string
|
|
268
|
+
): Service => ({
|
|
269
|
+
...balayageService,
|
|
270
|
+
slug,
|
|
271
|
+
subtitle,
|
|
272
|
+
title,
|
|
273
|
+
shortDescription,
|
|
274
|
+
duration,
|
|
275
|
+
price,
|
|
276
|
+
image: `https://picsum.photos/seed/overlay-${slug}/600/800`,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const overlayServices: Service[] = [
|
|
280
|
+
makeOverlayService(
|
|
281
|
+
"balayage",
|
|
282
|
+
"Hair Colouring",
|
|
283
|
+
"Balayage",
|
|
284
|
+
"Hand-painted, sun-kissed colour with a soft regrowth line and low upkeep.",
|
|
285
|
+
"2.5 - 3.5 hours",
|
|
286
|
+
"From £95"
|
|
287
|
+
),
|
|
288
|
+
makeOverlayService(
|
|
289
|
+
"full-colour",
|
|
290
|
+
"Hair Colouring",
|
|
291
|
+
"Full Colour",
|
|
292
|
+
"Complete colour coverage, from root touch-ups and natural tones to bold, fashion-forward shades.",
|
|
293
|
+
"1.5 - 2.5 hours",
|
|
294
|
+
"From £75"
|
|
295
|
+
),
|
|
296
|
+
makeOverlayService(
|
|
297
|
+
"restyle",
|
|
298
|
+
"Cutting & Treatment",
|
|
299
|
+
"Restyle",
|
|
300
|
+
"A transformative cut that takes your hair in a new direction: a new shape, length, or style.",
|
|
301
|
+
"1 - 1.5 hours",
|
|
302
|
+
"£65"
|
|
303
|
+
),
|
|
304
|
+
makeOverlayService(
|
|
305
|
+
"keratin-treatment",
|
|
306
|
+
"Cutting & Treatment",
|
|
307
|
+
"Keratin Treatment",
|
|
308
|
+
"Frizz-free smoothing sealed into the hair, with shine that lasts for months.",
|
|
309
|
+
"2 - 3 hours",
|
|
310
|
+
"£120"
|
|
311
|
+
),
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
export const DarkOverlayCards: Story = {
|
|
315
|
+
name: "Dark Overlay Cards",
|
|
316
|
+
render: (args) => ({
|
|
317
|
+
components: { ServicesCard },
|
|
318
|
+
setup() {
|
|
319
|
+
return { args, overlayServices };
|
|
320
|
+
},
|
|
321
|
+
template: `
|
|
322
|
+
<component is="style">
|
|
323
|
+
.services-card.dark-overlay-card {
|
|
324
|
+
--services-card-gap: 0;
|
|
325
|
+
--services-card-border-radius: 0.4rem;
|
|
326
|
+
--services-card-background-color: #1c1817;
|
|
327
|
+
--services-card-background-color-hover: #211c1b;
|
|
328
|
+
--services-card-border-colour: #2e2826;
|
|
329
|
+
--services-card-border-colour-hover: #6b5a4e;
|
|
330
|
+
--services-card-transform-hover: translateY(-0.4rem);
|
|
331
|
+
|
|
332
|
+
--image-wrapper-border-radius: 0;
|
|
333
|
+
--image-wrapper-border-image-zoom-transform: none;
|
|
334
|
+
--image-wrapper-details-scrim-colour: #141110;
|
|
335
|
+
--image-wrapper-details-text-colour: #f3ece4;
|
|
336
|
+
--image-wrapper-details-eyebrow-text-colour: #c9a24a;
|
|
337
|
+
--image-wrapper-details-gap: 0.4rem;
|
|
338
|
+
--eyebrow-text-font-family: "Poppins", sans-serif;
|
|
339
|
+
--eyebrow-text-font-style: normal;
|
|
340
|
+
--eyebrow-text-font-weight: 600;
|
|
341
|
+
--eyebrow-text-letter-spacing: 0.25em;
|
|
342
|
+
--image-wrapper-details-eyebrow-text-padding-inline: 3.6rem;
|
|
343
|
+
--image-wrapper-details-hero-text-padding-block: 0 2.8rem;
|
|
344
|
+
--image-wrapper-details-hero-text-padding-inline: 3.6rem;
|
|
345
|
+
|
|
346
|
+
--details-wrapper-padding-block: 3.6rem;
|
|
347
|
+
--details-wrapper-padding-inline: 3.6rem;
|
|
348
|
+
--description-text-colour: #a39a94;
|
|
349
|
+
--description-line-height: 1.75;
|
|
350
|
+
--description-font-weight: 300;
|
|
351
|
+
--meta-border-colour: #2e2826;
|
|
352
|
+
--meta-padding-block: 2.4rem 0;
|
|
353
|
+
--meta-text-colour: #8a817b;
|
|
354
|
+
--meta-duration-font-weight: 400;
|
|
355
|
+
--meta-price-font-weight: 400;
|
|
356
|
+
--meta-letter-spacing: 0.15em;
|
|
357
|
+
--meta-price-text-colour: #e8dfd6;
|
|
358
|
+
}
|
|
359
|
+
</component>
|
|
360
|
+
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(min(26rem, 100%), 1fr)); gap:4rem; padding:2.4rem; background:#121010;">
|
|
361
|
+
<ServicesCard
|
|
362
|
+
v-for="service in overlayServices"
|
|
363
|
+
:key="service.slug"
|
|
364
|
+
v-bind="args"
|
|
365
|
+
:service-data="service"
|
|
366
|
+
:href="'#' + service.slug"
|
|
367
|
+
titles-within-image-wrapper
|
|
368
|
+
:style-class-passthrough="['dark-overlay-card']"
|
|
369
|
+
/>
|
|
370
|
+
</div>
|
|
371
|
+
`,
|
|
372
|
+
}),
|
|
373
|
+
parameters: {
|
|
374
|
+
docs: {
|
|
375
|
+
description: {
|
|
376
|
+
story:
|
|
377
|
+
"A darker variant built from tokens: titles over the image with a dark scrim, a flush square-cornered image, a padded details panel, and a whole-card link whose hover lightens the border. Every value is a token, set on a class added with styleClassPassthrough; the eyebrow tokens are EyebrowText's own and inherit down into the card.",
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
};
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
exports[`ServicesCard > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="services-card">
|
|
5
|
-
<div class="image-wrapper"
|
|
5
|
+
<div class="grid-stack image-wrapper">
|
|
6
|
+
<div class="grid-stack__layer"><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>
|
|
7
|
+
<div class="grid-stack__layer"></div>
|
|
8
|
+
</div>
|
|
6
9
|
<div class="details-wrapper">
|
|
7
10
|
<div class="eyebrow-text large">A subtitle</div>
|
|
8
11
|
<h2 class="hero-text heading axis-horizontal">
|
package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts
CHANGED
|
@@ -15,7 +15,9 @@ type StoryArgs = {
|
|
|
15
15
|
buttonIcon?: string;
|
|
16
16
|
styleClassPassthrough?: string | string[];
|
|
17
17
|
/** Story-only — not a real ServicesCardGrid prop, see useStorySetup below. */
|
|
18
|
-
lineClamp?:
|
|
18
|
+
lineClamp?: 1 | 2 | 3 | 4 | 5;
|
|
19
|
+
/** Story-only — sets --services-card-grid-column-min-width on the wrapper, see useStorySetup below. */
|
|
20
|
+
cardMinWidth?: "200px" | "250px" | "300px" | "350px" | "400px";
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
const meta: Meta<StoryArgs> = {
|
|
@@ -52,10 +54,17 @@ const meta: Meta<StoryArgs> = {
|
|
|
52
54
|
description: "Additional CSS classes applied to the root element",
|
|
53
55
|
},
|
|
54
56
|
lineClamp: {
|
|
55
|
-
control: { type: "
|
|
57
|
+
control: { type: "inline-radio" },
|
|
58
|
+
options: [1, 2, 3, 4, 5],
|
|
56
59
|
description:
|
|
57
60
|
"Story-only control — sets ServicesCard's --description-line-clamp custom property on the grid wrapper (not a real component prop) to demo description clamping across cards",
|
|
58
61
|
},
|
|
62
|
+
cardMinWidth: {
|
|
63
|
+
control: { type: "inline-radio" },
|
|
64
|
+
options: ["200px", "250px", "300px", "350px", "400px"],
|
|
65
|
+
description:
|
|
66
|
+
"Story-only control — sets --services-card-grid-column-min-width on the grid wrapper (not a real component prop). Cards never go narrower than this; auto-fit then stretches each column to share the leftover space",
|
|
67
|
+
},
|
|
59
68
|
},
|
|
60
69
|
args: {
|
|
61
70
|
tag: "div",
|
|
@@ -66,6 +75,7 @@ const meta: Meta<StoryArgs> = {
|
|
|
66
75
|
buttonIcon: "mdi:arrow-right",
|
|
67
76
|
styleClassPassthrough: [],
|
|
68
77
|
lineClamp: 3,
|
|
78
|
+
cardMinWidth: "250px",
|
|
69
79
|
},
|
|
70
80
|
parameters: {
|
|
71
81
|
backgrounds: {
|
|
@@ -169,31 +179,56 @@ const lineClampServices: Service[] = [
|
|
|
169
179
|
),
|
|
170
180
|
];
|
|
171
181
|
|
|
182
|
+
// Twelve cards (divides evenly into 2, 3, 4 and 6 columns) to demo how cardMinWidth
|
|
183
|
+
// changes the column count as the canvas resizes.
|
|
184
|
+
const cardWidthServices: Service[] = [
|
|
185
|
+
...sampleServices,
|
|
186
|
+
...lineClampServices,
|
|
187
|
+
makeService("silk-press", "Silk Press", "Sleek, glossy straightening", "https://picsum.photos/seed/width-1/600/800"),
|
|
188
|
+
makeService("box-braids", "Box Braids", "Classic protective style", "https://picsum.photos/seed/width-2/600/800"),
|
|
189
|
+
makeService("scalp-treatment", "Scalp Treatment", "Healthy hair starts here", "https://picsum.photos/seed/width-3/600/800"),
|
|
190
|
+
];
|
|
191
|
+
|
|
172
192
|
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
173
193
|
|
|
194
|
+
// Hides the story-only lineClamp/cardMinWidth controls on stories whose fixtures can't
|
|
195
|
+
// show them (3 or fewer cards never wrap under auto-fit, and two-line descriptions
|
|
196
|
+
// never clamp) — use the Description Line Clamp and Card Width stories for those.
|
|
197
|
+
const hideDemoControls = {
|
|
198
|
+
lineClamp: { table: { disable: true } },
|
|
199
|
+
cardMinWidth: { table: { disable: true } },
|
|
200
|
+
};
|
|
201
|
+
|
|
174
202
|
/**
|
|
175
|
-
* lineClamp
|
|
176
|
-
* --description-line-clamp
|
|
177
|
-
*
|
|
203
|
+
* lineClamp and cardMinWidth are story-only controls (not real ServicesCardGrid props).
|
|
204
|
+
* They set --description-line-clamp (read by each ServicesCard's .description) and
|
|
205
|
+
* --services-card-grid-column-min-width (the grid's minmax() floor) on a wrapper div,
|
|
206
|
+
* and are stripped from the args bound to the component so they don't leak as attributes.
|
|
178
207
|
*/
|
|
179
208
|
function useStorySetup(args: StoryArgs) {
|
|
180
|
-
const
|
|
209
|
+
const wrapperStyle = computed(() => ({
|
|
181
210
|
"--description-line-clamp": String(args.lineClamp ?? 3),
|
|
211
|
+
"--services-card-grid-column-min-width": args.cardMinWidth ?? "250px",
|
|
182
212
|
}));
|
|
183
213
|
const componentArgs = computed(() => {
|
|
184
|
-
const { lineClamp: _lineClamp, ...rest } = args;
|
|
214
|
+
const { lineClamp: _lineClamp, cardMinWidth: _cardMinWidth, ...rest } = args;
|
|
185
215
|
return rest;
|
|
186
216
|
});
|
|
187
|
-
return {
|
|
217
|
+
return { wrapperStyle, componentArgs };
|
|
188
218
|
}
|
|
189
219
|
|
|
190
220
|
export const Default: Story = {
|
|
221
|
+
argTypes: hideDemoControls,
|
|
191
222
|
render: (args) => ({
|
|
192
223
|
components: { ServicesCardGrid },
|
|
193
224
|
setup() {
|
|
194
|
-
return { args, sampleServices };
|
|
225
|
+
return { ...useStorySetup(args), sampleServices };
|
|
195
226
|
},
|
|
196
|
-
template:
|
|
227
|
+
template: `
|
|
228
|
+
<div :style="wrapperStyle">
|
|
229
|
+
<ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
|
|
230
|
+
</div>
|
|
231
|
+
`,
|
|
197
232
|
}),
|
|
198
233
|
parameters: {
|
|
199
234
|
docs: {
|
|
@@ -205,6 +240,7 @@ export const Default: Story = {
|
|
|
205
240
|
};
|
|
206
241
|
|
|
207
242
|
export const CustomButtonText: Story = {
|
|
243
|
+
argTypes: hideDemoControls,
|
|
208
244
|
name: "Custom Button Text",
|
|
209
245
|
args: {
|
|
210
246
|
hrefBase: "/services/",
|
|
@@ -213,9 +249,13 @@ export const CustomButtonText: Story = {
|
|
|
213
249
|
render: (args) => ({
|
|
214
250
|
components: { ServicesCardGrid },
|
|
215
251
|
setup() {
|
|
216
|
-
return { args, sampleServices };
|
|
252
|
+
return { ...useStorySetup(args), sampleServices };
|
|
217
253
|
},
|
|
218
|
-
template:
|
|
254
|
+
template: `
|
|
255
|
+
<div :style="wrapperStyle">
|
|
256
|
+
<ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
|
|
257
|
+
</div>
|
|
258
|
+
`,
|
|
219
259
|
}),
|
|
220
260
|
parameters: {
|
|
221
261
|
docs: {
|
|
@@ -227,6 +267,7 @@ export const CustomButtonText: Story = {
|
|
|
227
267
|
};
|
|
228
268
|
|
|
229
269
|
export const ConfigOverrides: Story = {
|
|
270
|
+
argTypes: hideDemoControls,
|
|
230
271
|
name: "Config Overrides",
|
|
231
272
|
args: {
|
|
232
273
|
eyebrowConfig: { tag: "p", fontSize: "small" },
|
|
@@ -235,9 +276,13 @@ export const ConfigOverrides: Story = {
|
|
|
235
276
|
render: (args) => ({
|
|
236
277
|
components: { ServicesCardGrid },
|
|
237
278
|
setup() {
|
|
238
|
-
return { args, sampleServices };
|
|
279
|
+
return { ...useStorySetup(args), sampleServices };
|
|
239
280
|
},
|
|
240
|
-
template:
|
|
281
|
+
template: `
|
|
282
|
+
<div :style="wrapperStyle">
|
|
283
|
+
<ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
|
|
284
|
+
</div>
|
|
285
|
+
`,
|
|
241
286
|
}),
|
|
242
287
|
parameters: {
|
|
243
288
|
docs: {
|
|
@@ -250,13 +295,18 @@ export const ConfigOverrides: Story = {
|
|
|
250
295
|
};
|
|
251
296
|
|
|
252
297
|
export const SingleCard: Story = {
|
|
298
|
+
argTypes: hideDemoControls,
|
|
253
299
|
name: "Single Card",
|
|
254
300
|
render: (args) => ({
|
|
255
301
|
components: { ServicesCardGrid },
|
|
256
302
|
setup() {
|
|
257
|
-
return { args, sampleServices };
|
|
303
|
+
return { ...useStorySetup(args), sampleServices };
|
|
258
304
|
},
|
|
259
|
-
template:
|
|
305
|
+
template: `
|
|
306
|
+
<div :style="wrapperStyle">
|
|
307
|
+
<ServicesCardGrid v-bind="componentArgs" :services-data="[sampleServices[0]]" />
|
|
308
|
+
</div>
|
|
309
|
+
`,
|
|
260
310
|
}),
|
|
261
311
|
parameters: {
|
|
262
312
|
docs: {
|
|
@@ -268,13 +318,18 @@ export const SingleCard: Story = {
|
|
|
268
318
|
};
|
|
269
319
|
|
|
270
320
|
export const EmptyData: Story = {
|
|
321
|
+
argTypes: hideDemoControls,
|
|
271
322
|
name: "Empty Data",
|
|
272
323
|
render: (args) => ({
|
|
273
324
|
components: { ServicesCardGrid },
|
|
274
325
|
setup() {
|
|
275
|
-
return { args };
|
|
326
|
+
return { ...useStorySetup(args) };
|
|
276
327
|
},
|
|
277
|
-
template:
|
|
328
|
+
template: `
|
|
329
|
+
<div :style="wrapperStyle">
|
|
330
|
+
<ServicesCardGrid v-bind="componentArgs" :services-data="[]" />
|
|
331
|
+
</div>
|
|
332
|
+
`,
|
|
278
333
|
}),
|
|
279
334
|
parameters: {
|
|
280
335
|
docs: {
|
|
@@ -296,7 +351,7 @@ export const DescriptionLineClamp: Story = {
|
|
|
296
351
|
return { ...useStorySetup(args), lineClampServices };
|
|
297
352
|
},
|
|
298
353
|
template: `
|
|
299
|
-
<div :style="
|
|
354
|
+
<div :style="wrapperStyle">
|
|
300
355
|
<ServicesCardGrid v-bind="componentArgs" :services-data="lineClampServices" />
|
|
301
356
|
</div>
|
|
302
357
|
`,
|
|
@@ -310,3 +365,29 @@ export const DescriptionLineClamp: Story = {
|
|
|
310
365
|
},
|
|
311
366
|
},
|
|
312
367
|
};
|
|
368
|
+
|
|
369
|
+
export const CardWidth: Story = {
|
|
370
|
+
name: "Card Width",
|
|
371
|
+
args: {
|
|
372
|
+
cardMinWidth: "300px",
|
|
373
|
+
},
|
|
374
|
+
render: (args) => ({
|
|
375
|
+
components: { ServicesCardGrid },
|
|
376
|
+
setup() {
|
|
377
|
+
return { ...useStorySetup(args), cardWidthServices };
|
|
378
|
+
},
|
|
379
|
+
template: `
|
|
380
|
+
<div :style="wrapperStyle">
|
|
381
|
+
<ServicesCardGrid v-bind="componentArgs" :services-data="cardWidthServices" />
|
|
382
|
+
</div>
|
|
383
|
+
`,
|
|
384
|
+
}),
|
|
385
|
+
parameters: {
|
|
386
|
+
docs: {
|
|
387
|
+
description: {
|
|
388
|
+
story:
|
|
389
|
+
"Twelve cards with the cardMinWidth control driving --services-card-grid-column-min-width. The value is a floor, not a fixed width: auto-fit fits as many columns as that minimum allows, then stretches them to fill the row. Change the control or resize the canvas to see the column count change.",
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="services-card-grid">
|
|
5
5
|
<div class="services-card">
|
|
6
|
-
<div class="
|
|
6
|
+
<div class="grid-stack image-wrapper">
|
|
7
|
+
<div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="lazy" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
|
|
8
|
+
<div class="grid-stack__layer"></div>
|
|
9
|
+
</div>
|
|
7
10
|
<div class="details-wrapper">
|
|
8
11
|
<div class="eyebrow-text large">Subtitle for Locs Installation</div>
|
|
9
12
|
<h2 class="hero-text heading axis-horizontal">
|
|
@@ -23,7 +26,10 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
|
|
|
23
26
|
</div>
|
|
24
27
|
</div>
|
|
25
28
|
<div class="services-card">
|
|
26
|
-
<div class="
|
|
29
|
+
<div class="grid-stack image-wrapper">
|
|
30
|
+
<div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="lazy" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
|
|
31
|
+
<div class="grid-stack__layer"></div>
|
|
32
|
+
</div>
|
|
27
33
|
<div class="details-wrapper">
|
|
28
34
|
<div class="eyebrow-text large">Subtitle for Locs Retwist</div>
|
|
29
35
|
<h2 class="hero-text heading axis-horizontal">
|
|
@@ -43,7 +49,10 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
|
|
|
43
49
|
</div>
|
|
44
50
|
</div>
|
|
45
51
|
<div class="services-card">
|
|
46
|
-
<div class="
|
|
52
|
+
<div class="grid-stack image-wrapper">
|
|
53
|
+
<div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
|
|
54
|
+
<div class="grid-stack__layer"></div>
|
|
55
|
+
</div>
|
|
47
56
|
<div class="details-wrapper">
|
|
48
57
|
<div class="eyebrow-text large">Subtitle for Colour Treatment</div>
|
|
49
58
|
<h2 class="hero-text heading axis-horizontal">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { ref } from "vue";
|
|
3
|
-
import type { Slots } from "vue";
|
|
3
|
+
import type { Slot, Slots } from "vue";
|
|
4
4
|
import { mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
5
5
|
import { useAriaDescribedById } from "../useAriaDescribedById";
|
|
6
6
|
|
|
@@ -132,16 +132,16 @@ describe("useAriaDescribedById", () => {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
it("picks up a description slot added after setup", () => {
|
|
135
|
-
const slots:
|
|
136
|
-
const { descriptionId, ariaDescribedby } = useAriaDescribedById("email", ref(false), slots);
|
|
135
|
+
const slots: Record<string, Slot> = {};
|
|
136
|
+
const { descriptionId, ariaDescribedby } = useAriaDescribedById("email", ref(false), slots as Slots);
|
|
137
137
|
expect(ariaDescribedby()).toBeNull();
|
|
138
138
|
slots.descriptionText = () => [];
|
|
139
139
|
expect(ariaDescribedby()).toBe(descriptionId);
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
it("drops a description slot removed after setup", () => {
|
|
143
|
-
const slots:
|
|
144
|
-
const { ariaDescribedby } = useAriaDescribedById("email", ref(false), slots);
|
|
143
|
+
const slots: Record<string, Slot> = { descriptionText: () => [] };
|
|
144
|
+
const { ariaDescribedby } = useAriaDescribedById("email", ref(false), slots as Slots);
|
|
145
145
|
expect(ariaDescribedby()).toBeTruthy();
|
|
146
146
|
delete slots.descriptionText;
|
|
147
147
|
expect(ariaDescribedby()).toBeNull();
|