srcdev-nuxt-components 9.1.7 → 9.1.9
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/page-hero-highlights.md +35 -18
- package/.claude/skills/css-grid-max-width-gutters.md +38 -2
- package/app/assets/styles/setup/01.config/_head.css +24 -12
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +29 -20
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +523 -0
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +340 -0
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +335 -0
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +8 -6
- package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +50 -11
- package/app/components/04.templates/page-hero-highlights/PageHeroHighlightsHeader.vue +12 -2
- package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +23 -12
- package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +12 -21
- package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +1 -1
- package/app/pages/page-hero-highlights.vue +127 -8
- package/package.json +1 -1
|
@@ -17,8 +17,8 @@ The layout uses a 4-row CSS Grid with `subgrid` — no `translate`, negative mar
|
|
|
17
17
|
| `tag` | `"div" \| "section" \| "main"` | `"div"` | Root element tag |
|
|
18
18
|
| `highlightsEqualWidths` | `boolean` | `false` | Equal-width grid columns for highlight items |
|
|
19
19
|
| `highlightsJustify` | `"start" \| "center" \| "end" \| "space-between" \| "space-around"` | `"start"` | Alignment of highlight items along the main axis |
|
|
20
|
-
| `
|
|
21
|
-
| `contentAlign` | `"start" \| "center"` | `"center"` | When `
|
|
20
|
+
| `widthConstrained` | `boolean` | `false` | When `true`, caps the central column at `--width-constrained` (default `1064px`). Gutters grow responsively to enforce the constraint. |
|
|
21
|
+
| `contentAlign` | `"start" \| "center"` | `"center"` | When `widthConstrained` is `true`: `"center"` grows gutters equally; `"start"` pins content to the left with a fixed left gutter. |
|
|
22
22
|
| `contentPanel` | `boolean` | `true` | When `true`, renders a decorative panel behind the content slot and offsets the highlights strip. Set to `false` for a flat layout with no backdrop. |
|
|
23
23
|
| `highlightTitleBaseline`| `boolean` | `false` | When `true`, fixes the highlight title row to a set height so titles align at a common baseline. Override `--highlight-title-height` to tune. |
|
|
24
24
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root element |
|
|
@@ -66,7 +66,7 @@ Each slot accepts any content, but these library components are natural fits:
|
|
|
66
66
|
Example with `HeroText` in the header slot:
|
|
67
67
|
|
|
68
68
|
```vue
|
|
69
|
-
<PageHeroHighlights tag="section"
|
|
69
|
+
<PageHeroHighlights tag="section" :width-constrained="true">
|
|
70
70
|
<template #header="{ headingId }">
|
|
71
71
|
<HeroText :heading-id="headingId" text="Welcome back" accent-text="Simon" />
|
|
72
72
|
</template>
|
|
@@ -106,11 +106,13 @@ Located at: `app/components/04.templates/page-hero-highlights/PageHeroHighlights
|
|
|
106
106
|
|
|
107
107
|
### CSS tokens
|
|
108
108
|
|
|
109
|
-
| Token
|
|
110
|
-
|
|
|
111
|
-
| `--phh-padding-block`
|
|
112
|
-
| `--phh-
|
|
113
|
-
| `--phh-
|
|
109
|
+
| Token | Default | Description |
|
|
110
|
+
| -------------------------- | ---------------- | ---------------------------------------------------- |
|
|
111
|
+
| `--phh-padding-block-mobile` | `1.6rem 3.2rem` | Block padding (start end) at mobile widths |
|
|
112
|
+
| `--phh-padding-block-tablet` | `2.4rem 4.8rem` | Block padding (start end) at ≥768px |
|
|
113
|
+
| `--phh-padding-block-desktop` | `3.2rem 6.4rem` | Block padding (start end) at ≥1024px |
|
|
114
|
+
| `--phh-gap` | `1.6rem` | Gap between `#start` and `#end` areas |
|
|
115
|
+
| `--phh-end-gap` | `0.8rem` | Gap between items within `#end` |
|
|
114
116
|
|
|
115
117
|
### Usage
|
|
116
118
|
|
|
@@ -170,17 +172,26 @@ By default, highlight items size to their content (`flex-wrap`). Pass `:highligh
|
|
|
170
172
|
|
|
171
173
|
## Constraining the central column width
|
|
172
174
|
|
|
173
|
-
Pass
|
|
175
|
+
Pass `:width-constrained="true"` to cap the content column at `--width-constrained` (default `1064px`). The gutters grow responsively to enforce it — full-bleed backgrounds are unaffected and `subgrid` continues to work. Use `content-align` to pin to the left or centre:
|
|
174
176
|
|
|
175
177
|
```vue
|
|
176
|
-
<!-- Centred, capped at 1064px -->
|
|
177
|
-
<PageHeroHighlights
|
|
178
|
+
<!-- Centred, capped at --width-constrained (1064px) -->
|
|
179
|
+
<PageHeroHighlights :width-constrained="true" content-align="center">...</PageHeroHighlights>
|
|
178
180
|
|
|
179
|
-
<!-- Left-pinned
|
|
180
|
-
<PageHeroHighlights
|
|
181
|
+
<!-- Left-pinned (right side takes remaining space) -->
|
|
182
|
+
<PageHeroHighlights :width-constrained="true" content-align="start">...</PageHeroHighlights>
|
|
181
183
|
```
|
|
182
184
|
|
|
183
|
-
|
|
185
|
+
The maximum width value and gutter sizes are all CSS tokens — override them via `styleClassPassthrough` if you need different values:
|
|
186
|
+
|
|
187
|
+
```css
|
|
188
|
+
.my-page-hero {
|
|
189
|
+
--width-constrained: 1200px;
|
|
190
|
+
--page-hero-highlights-gutter-desktop: 48px;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
See [css-grid-width-constrained-gutters.md](../css-grid-width-constrained-gutters.md) for the underlying pattern explanation.
|
|
184
195
|
|
|
185
196
|
## Local style override scaffold
|
|
186
197
|
|
|
@@ -199,6 +210,12 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
199
210
|
─────────────────────────────────────────────────────────────────── */
|
|
200
211
|
.page-hero-highlights {
|
|
201
212
|
&.my-page-hero {
|
|
213
|
+
/* Grid layout */
|
|
214
|
+
/* --width-constrained: 1064px; */
|
|
215
|
+
/* --page-hero-highlights-gutter-mobile: 16px; */
|
|
216
|
+
/* --page-hero-highlights-gutter-tablet: 40px; */
|
|
217
|
+
/* --page-hero-highlights-gutter-desktop: 32px; */
|
|
218
|
+
|
|
202
219
|
/* Header zone */
|
|
203
220
|
/* --header-row-background-colour: darkblue; */
|
|
204
221
|
|
|
@@ -239,8 +256,6 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
239
256
|
</style>
|
|
240
257
|
```
|
|
241
258
|
|
|
242
|
-
> **Note:** The minimum gutter width (`16px`) and layout behaviour are not overridable via CSS custom properties. Use the `max-width` and `content-align` props to control column constraints.
|
|
243
|
-
|
|
244
259
|
## Grid structure (reference)
|
|
245
260
|
|
|
246
261
|
```text
|
|
@@ -254,6 +269,8 @@ row4: page content (never underflows highlights)
|
|
|
254
269
|
`.header-row` spans cols 1–3, rows 1–2 (edge-to-edge bg). `.header-slot` is placed in row 1 only.
|
|
255
270
|
`.content-row` spans cols 1–3, rows 3–4 (bg fills behind highlights; `.content-slot` is placed in row 4 only). The decorative border behind `.content-slot` is rendered via `.content-row:before` — there is no separate DOM element for it.
|
|
256
271
|
|
|
272
|
+
Grid columns are determined entirely by CSS — no `v-bind`. The `widthConstrained` and `contentAlign` props add CSS classes (`width-constrained`, `start`, `center`) which select the appropriate `grid-template-columns` rule.
|
|
273
|
+
|
|
257
274
|
## Layout pitfall: do not use `grid-template-rows: subgrid` inside `.highlights-row`
|
|
258
275
|
|
|
259
276
|
The `.highlights-row` element spans rows 2–3 of the parent grid (the "straddle"). If you add an inner grid to `.highlights-row` (e.g. to extend `equal-widths` behaviour) and include `grid-template-rows: subgrid`, auto-placed items will only occupy row 1 of the subgrid (= parent row 2). Parent row 3 collapses to 0-height, destroying the straddle effect — `.content-row` appears immediately below the highlights instead of overlapping it.
|
|
@@ -280,5 +297,5 @@ The `.highlights-row` element spans rows 2–3 of the parent grid (the "straddle
|
|
|
280
297
|
- Component is auto-imported in Nuxt — no import needed.
|
|
281
298
|
- Lives in `app/components/04.templates/page-hero-highlights/`.
|
|
282
299
|
- Storybook title: `"Templates/PageHeroHighlights"`.
|
|
283
|
-
-
|
|
284
|
-
-
|
|
300
|
+
- **`contentAlign` has no effect when `widthConstrained` is `false`** — both gutters hold their responsive default.
|
|
301
|
+
- **Gutter sizes are CSS tokens** — `--page-hero-highlights-gutter-mobile/tablet/desktop` are all overridable. The responsive switching between them (via `@container`) is handled internally and cannot be overridden.
|
|
@@ -31,10 +31,47 @@ Left gutter stays fixed, content column is capped at `MAX_WIDTH`, remaining spac
|
|
|
31
31
|
|
|
32
32
|
## In Vue with a prop
|
|
33
33
|
|
|
34
|
+
### Option A — CSS tokens + class selectors (preferred when max-width is a fixed design value)
|
|
35
|
+
|
|
36
|
+
When the max-width and gutter values are fixed design tokens (not arbitrary consumer strings), express the logic entirely in CSS using a boolean `maxWidth` prop that adds a class:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
interface Props {
|
|
40
|
+
maxWidth?: boolean;
|
|
41
|
+
contentAlign?: "start" | "center";
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
.component {
|
|
47
|
+
--max-width: 1064px;
|
|
48
|
+
--gutter: 16px;
|
|
49
|
+
|
|
50
|
+
display: grid;
|
|
51
|
+
|
|
52
|
+
&.max-width {
|
|
53
|
+
grid-template-columns: var(--gutter) 1fr var(--gutter);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&:not(.max-width) {
|
|
57
|
+
&.start {
|
|
58
|
+
grid-template-columns: var(--gutter) minmax(0, var(--max-width)) minmax(var(--gutter), 1fr);
|
|
59
|
+
}
|
|
60
|
+
&.center {
|
|
61
|
+
grid-template-columns: max(var(--gutter), (100% - var(--max-width)) / 2) 1fr
|
|
62
|
+
max(var(--gutter), (100% - var(--max-width)) / 2);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Consumers can override `--max-width` and `--gutter` via `styleClassPassthrough` without touching the prop. This is the approach used by `PageHeroHighlights`.
|
|
69
|
+
|
|
70
|
+
### Option B — computed string with `v-bind` (use when max-width is a dynamic consumer prop)
|
|
71
|
+
|
|
34
72
|
Because `v-bind()` in `<style>` can't be nested inside CSS functions like `max()`, build the column string as a computed and bind the whole value:
|
|
35
73
|
|
|
36
74
|
```ts
|
|
37
|
-
// Props
|
|
38
75
|
interface Props {
|
|
39
76
|
maxWidth?: string; // e.g. "1064px"
|
|
40
77
|
contentAlign?: "start" | "center";
|
|
@@ -45,7 +82,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
45
82
|
contentAlign: "center",
|
|
46
83
|
});
|
|
47
84
|
|
|
48
|
-
// Computed column string
|
|
49
85
|
const gridColumns = computed(() => {
|
|
50
86
|
if (!props.maxWidth) return "16px 1fr 16px";
|
|
51
87
|
if (props.contentAlign === "start") return `16px minmax(0, ${props.maxWidth}) 1fr`;
|
|
@@ -15,19 +15,31 @@ html {
|
|
|
15
15
|
transition:
|
|
16
16
|
background-color 0.4s ease,
|
|
17
17
|
color 0.4s ease;
|
|
18
|
-
|
|
18
|
+
scrollbar-gutter: stable;
|
|
19
19
|
|
|
20
20
|
overflow-x: clip;
|
|
21
|
-
}
|
|
22
|
-
body {
|
|
23
|
-
background-color: var(--page-bg, lightgray);
|
|
24
|
-
color: var(--colour-text-default);
|
|
25
|
-
font-family: var(--font-family);
|
|
26
|
-
font-size: var(--step-4);
|
|
27
|
-
min-height: 100dvh;
|
|
28
|
-
transition:
|
|
29
|
-
background-color 0.4s ease,
|
|
30
|
-
color 0.4s ease;
|
|
31
21
|
|
|
32
|
-
|
|
22
|
+
body {
|
|
23
|
+
background-color: var(--page-bg, lightgray);
|
|
24
|
+
color: var(--colour-text-default);
|
|
25
|
+
font-family: var(--font-family);
|
|
26
|
+
font-size: var(--step-4);
|
|
27
|
+
/* min-height: 100dvh; */
|
|
28
|
+
transition:
|
|
29
|
+
background-color 0.4s ease,
|
|
30
|
+
color 0.4s ease;
|
|
31
|
+
|
|
32
|
+
overflow-x: clip;
|
|
33
|
+
|
|
34
|
+
#__nuxt {
|
|
35
|
+
height: 100%;
|
|
36
|
+
div {
|
|
37
|
+
.page-layout {
|
|
38
|
+
min-block-size: 100svh;
|
|
39
|
+
display: grid;
|
|
40
|
+
grid-template-rows: auto 1fr auto;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
33
45
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
@mouseleave="resetHoverNavToActive"
|
|
18
18
|
@mouseover="handleNavHover"
|
|
19
19
|
>
|
|
20
|
-
<li v-for="item in navItemData.main" :key="item.href" :class="item.cssName">
|
|
20
|
+
<li v-for="item in navItemData.main" :key="item.href" :class="[item.cssName, { 'is-active': activeHref === item.href }]">
|
|
21
21
|
<NuxtLink :href="item.href" :external="item.isExternal || undefined" class="site-nav-link" data-nav-item>
|
|
22
22
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
23
23
|
{{ item.text }}
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
@mouseover="handlePanelHover"
|
|
71
71
|
@mouseleave="resetHoverPanelToActive"
|
|
72
72
|
>
|
|
73
|
-
<li v-for="item in navItemData.main" :key="item.href" :class="item.cssName">
|
|
73
|
+
<li v-for="item in navItemData.main" :key="item.href" :class="[item.cssName, { 'is-active': activeHref === item.href }]">
|
|
74
74
|
<NuxtLink
|
|
75
75
|
:href="item.href"
|
|
76
76
|
:external="item.isExternal || undefined"
|
|
@@ -231,9 +231,7 @@ const initNavDecorators = () => {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
const activeLink =
|
|
234
|
-
links.find((el) => el.
|
|
235
|
-
links.find((el) => el.classList.contains("router-link-active")) ??
|
|
236
|
-
links[0];
|
|
234
|
+
links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
237
235
|
if (!activeLink) return;
|
|
238
236
|
|
|
239
237
|
currentActiveNavLink = activeLink;
|
|
@@ -337,9 +335,7 @@ const initPanelDecorators = () => {
|
|
|
337
335
|
}
|
|
338
336
|
|
|
339
337
|
const activeLink =
|
|
340
|
-
links.find((el) => el.
|
|
341
|
-
links.find((el) => el.classList.contains("router-link-active")) ??
|
|
342
|
-
links[0];
|
|
338
|
+
links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
343
339
|
if (!activeLink) return;
|
|
344
340
|
|
|
345
341
|
currentActivePanelLink = activeLink;
|
|
@@ -352,11 +348,20 @@ const initPanelDecorators = () => {
|
|
|
352
348
|
|
|
353
349
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
354
350
|
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
// requestAnimationFrame defers the measurement until after browser layout,
|
|
358
|
-
// preventing stale offsetLeft reads and racing with hover setTimeouts.
|
|
351
|
+
// Compute active href from route directly — avoids racing against Vue Router's
|
|
352
|
+
// class application timing when reading router-link-exact-active from the DOM.
|
|
359
353
|
const route = useRoute();
|
|
354
|
+
|
|
355
|
+
const activeHref = computed(() => {
|
|
356
|
+
const items = props.navItemData.main ?? [];
|
|
357
|
+
const exact = items.find((item) => item.href && route.path === item.href);
|
|
358
|
+
if (exact) return exact.href ?? null;
|
|
359
|
+
return (
|
|
360
|
+
items
|
|
361
|
+
.filter((item) => item.href && route.path.startsWith(item.href + "/"))
|
|
362
|
+
.sort((a, b) => (b.href?.length ?? 0) - (a.href?.length ?? 0))[0]?.href ?? null
|
|
363
|
+
);
|
|
364
|
+
});
|
|
360
365
|
watch(
|
|
361
366
|
() => route.path,
|
|
362
367
|
() => {
|
|
@@ -379,12 +384,16 @@ useResizeObserver(navRef, () => {
|
|
|
379
384
|
|
|
380
385
|
onClickOutside(navRef, closeMenu);
|
|
381
386
|
|
|
387
|
+
const router = useRouter();
|
|
388
|
+
|
|
382
389
|
onMounted(async () => {
|
|
383
390
|
await nextTick();
|
|
384
391
|
checkOverflow();
|
|
385
392
|
isLoaded.value = true;
|
|
386
|
-
await
|
|
387
|
-
|
|
393
|
+
await router.isReady();
|
|
394
|
+
requestAnimationFrame(() => {
|
|
395
|
+
initNavDecorators();
|
|
396
|
+
});
|
|
388
397
|
});
|
|
389
398
|
|
|
390
399
|
watch(isCollapsed, async (collapsed) => {
|
|
@@ -580,9 +589,9 @@ watch(
|
|
|
580
589
|
outline: none;
|
|
581
590
|
}
|
|
582
591
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
592
|
+
}
|
|
593
|
+
li.is-active .site-nav-link {
|
|
594
|
+
color: var(--_link-active-color);
|
|
586
595
|
}
|
|
587
596
|
}
|
|
588
597
|
|
|
@@ -773,9 +782,9 @@ watch(
|
|
|
773
782
|
outline: none;
|
|
774
783
|
}
|
|
775
784
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
785
|
+
}
|
|
786
|
+
li.is-active .site-nav-panel-link {
|
|
787
|
+
color: var(--_panel-link-active-color);
|
|
779
788
|
}
|
|
780
789
|
}
|
|
781
790
|
}
|