srcdev-nuxt-components 9.3.0 → 9.3.1
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 +66 -0
- package/.claude/settings.json +1 -1
- package/.claude/skills/component-local-style-override.md +45 -0
- package/.claude/skills/components/accordian-core.md +26 -5
- package/.claude/skills/components/expanding-panel-classic.md +221 -0
- package/.claude/skills/components/expanding-panel.md +8 -8
- package/.claude/skills/components/navigation-items.md +51 -0
- package/.claude/skills/components/responsive-header.md +161 -0
- package/.claude/skills/components/site-header.md +98 -0
- package/.claude/skills/index.md +6 -1
- package/.claude/skills/page-transitions.md +116 -0
- package/.claude/skills/theming-form-geometry-tokens.md +12 -0
- package/.claude/skills/theming-override-default.md +14 -0
- package/.claude/skills/theming-partial-override.md +17 -0
- package/.vscode/srcdev-component-accordian-core.code-snippets +74 -0
- package/.vscode/srcdev-component-expanding-panel-classic.code-snippets +121 -0
- package/.vscode/srcdev-component-expanding-panel.code-snippets +1 -3
- package/.vscode/srcdev-component-responsive-header.code-snippets +46 -0
- package/.vscode/srcdev-component-site-header.code-snippets +74 -0
- package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +19 -11
- package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +93 -0
- package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +31 -0
- package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +31 -0
- package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +7 -21
- package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +10 -10
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +35 -37
- package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +5 -6
- package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +5 -13
- package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +103 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +191 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +293 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +514 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +59 -0
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +66 -0
- package/app/components/{responsive-header → 03.organisms/responsive-header}/NavigationItems.vue +21 -28
- package/app/components/{responsive-header → 03.organisms/responsive-header}/ResponsiveHeader.vue +141 -43
- package/app/components/03.organisms/responsive-header/stories/NavigationItems.stories.ts +80 -0
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +122 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +155 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +319 -0
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +34 -0
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +72 -0
- package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +60 -0
- package/app/components/03.organisms/site-header/SiteHeader.vue +96 -0
- package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +135 -0
- package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +103 -0
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +15 -0
- package/app/layouts/default.vue +64 -67
- package/package.json +4 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# ResponsiveHeader Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`ResponsiveHeader` is an adaptive main-navigation bar: it measures each nav item's real
|
|
6
|
+
rendered width on mount (and on resize), decides which items fit, and collapses whatever
|
|
7
|
+
doesn't fit into a burger/overflow dropdown rendered by `NavigationItems`. Supports
|
|
8
|
+
multi-level dropdowns (`childLinks`) at both the top-bar and overflow-panel level.
|
|
9
|
+
|
|
10
|
+
Most consumers should reach for [`SiteHeader`](site-header.md) instead, which wraps this
|
|
11
|
+
component together with `PageRow` and `SkipLinks` — use `ResponsiveHeader` directly only if
|
|
12
|
+
you need a different outer page-header structure.
|
|
13
|
+
|
|
14
|
+
[`NavigationItems`](navigation-items.md) is an internal component rendered inside
|
|
15
|
+
`ResponsiveHeader`'s overflow panel — it's not meant to be used standalone in a real app,
|
|
16
|
+
though it's independently testable/storyable.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## The measurement pipeline (read this before touching layout/CSS here)
|
|
21
|
+
|
|
22
|
+
On mount, a two-phase pass measures `navigationWrapperRects`/`secondaryNavRects`/each nav
|
|
23
|
+
item's `getBoundingClientRect()`/`offsetWidth`, then marks each item's `config.visible`.
|
|
24
|
+
Items with `visible: false` get a `visually-hidden` class in the top bar and are handed to
|
|
25
|
+
`NavigationItems` to render in the overflow panel instead. The pass re-runs on
|
|
26
|
+
`ResizeObserver` events targeting the root `.navigation` element, and once more after
|
|
27
|
+
`document.fonts.ready` resolves (a fonts-still-loading correction).
|
|
28
|
+
|
|
29
|
+
**This pipeline only re-measures when the *observed wrapper's own box size* changes.** Two
|
|
30
|
+
known failure modes, both fixed 2026-08-21 — keep them in mind before adding new nav content:
|
|
31
|
+
|
|
32
|
+
1. **Unsized async content (icons).** The chevron and any `iconName` decorator icons render
|
|
33
|
+
via the `Icon` component, which resolves its SVG asynchronously. If an icon element has no
|
|
34
|
+
reserved `width`/`height`, it measures at ~0 during the pass and then pops in and widens
|
|
35
|
+
the item afterward — with nothing to detect it, since the wrapper's box didn't change size.
|
|
36
|
+
`.decorator-icon` and the chevron `.icon` both reserve `1.35em` square for this reason.
|
|
37
|
+
Any new icon-bearing element added to a nav item must reserve its own size too.
|
|
38
|
+
2. **Viewport-relative (`vw`/`clamp()`) font-size.** `html` has `scrollbar-gutter: stable`,
|
|
39
|
+
which keeps the wrapper's own box width constant when the scrollbar toggles — but does
|
|
40
|
+
nothing to stabilise the `vw` unit itself, so a fluid ancestor font-size can drift nav-item
|
|
41
|
+
text width without ever re-triggering a re-measurement. This is why
|
|
42
|
+
`--responsive-header-link-font-size` exists (see tokens below) — set it to a fixed value
|
|
43
|
+
rather than leaving it to inherit a fluid ancestor font-size.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## The dropdown "safe triangle" (read this before touching hover-close logic)
|
|
48
|
+
|
|
49
|
+
`handleNavigationItemHover()` used to close every open top-bar dropdown the instant the mouse
|
|
50
|
+
entered **any** `.main-navigation-item` — including a sibling item the cursor only crossed in
|
|
51
|
+
transit. Moving the mouse diagonally from a dropdown's summary down into its own
|
|
52
|
+
`.main-navigation-sub-nav` panel briefly dips through the 12px gap between them
|
|
53
|
+
(`.main-navigation-sub-nav`'s `translate: 0 12px`), and at some cursor angles through a
|
|
54
|
+
neighbouring item's hit area — closing the dropdown before the user reached it.
|
|
55
|
+
|
|
56
|
+
**A pure-CSS "safe triangle" bridge (`clip-path` on a pseudo-element) was tried first and
|
|
57
|
+
reverted — don't reintroduce it without solving the problem below first.** For such a bridge to
|
|
58
|
+
escape `.main-navigation-item`'s `overflow: hidden` the same way `.main-navigation-sub-nav`
|
|
59
|
+
does, its *containing block* must resolve to `.navigation` (the only positioned ancestor above
|
|
60
|
+
the clipping box). But `.main-navigation-sub-nav` only manages this because it has no explicit
|
|
61
|
+
`top`/`left` (containing-block choice is irrelevant to where it visually lands) — a bridge
|
|
62
|
+
placed to *visually* sit under a specific trigger needs `top`/`left`/`inline-size` that resolve
|
|
63
|
+
against something local, which forces it onto a positioned ancestor, which is always either
|
|
64
|
+
already inside the `overflow: hidden` box (gets clipped, invisible, protects nothing) or itself
|
|
65
|
+
becomes the sub-nav's new containing block (moves the *panel* inside the clipped box instead —
|
|
66
|
+
the first attempt's mistake). A commented-out `/* position: relative; */` still sitting in the
|
|
67
|
+
`:last-child` override is evidence someone hit this exact trap once before, independently.
|
|
68
|
+
Neither approach is fixable without restructuring how the panel escapes clipping, which the
|
|
69
|
+
collapse-measurement pipeline depends on.
|
|
70
|
+
|
|
71
|
+
**Fixed (2026-08-21) with a JS hover-intent delay instead.** `handleNavigationItemHover()` now
|
|
72
|
+
calls `scheduleCloseAllNavigationDetails()`, which delays the actual close by
|
|
73
|
+
`HOVER_CLOSE_DELAY` (200ms) rather than firing it immediately. Two things cancel the pending
|
|
74
|
+
close before it fires:
|
|
75
|
+
|
|
76
|
+
- `handleSummaryHover()` (reaching a summary — cancels, then does its own close-others/toggle)
|
|
77
|
+
- a `mouseenter` on `.main-navigation-sub-nav` itself, via `handleSubNavHover()`
|
|
78
|
+
|
|
79
|
+
So a brief diagonal dip through a sibling's hit area survives (the close never actually runs
|
|
80
|
+
before the cursor reaches its destination), while genuinely moving to a different part of the
|
|
81
|
+
page still closes the dropdown promptly. `closeAllTimer` is cleared `onUnmounted` to avoid a
|
|
82
|
+
stray close firing against stale refs after the component's gone.
|
|
83
|
+
|
|
84
|
+
**`handleSummaryHover` never toggles — it only ever ensures the summary it's called on is
|
|
85
|
+
open.** A real mouse click moves focus to the clicked element *before* the click event fires, so
|
|
86
|
+
clicking a summary the mouse had just hover-opened dispatches both a `focusin` (→
|
|
87
|
+
`handleSummaryHover`) and a `click` (→ `handleSummaryAction`) in quick succession. If
|
|
88
|
+
`handleSummaryHover` toggled (as it did until 2026-08-21), that focusin would flip an
|
|
89
|
+
already-open item closed, and the click's own toggle would immediately flip it back open — a
|
|
90
|
+
visible open→closed→open flicker on every click, and the click effectively did nothing. Only
|
|
91
|
+
`handleSummaryAction` (the explicit click) is allowed to close a dropdown; hover/focus is
|
|
92
|
+
idempotent-safe to fire redundantly. `@vue/test-utils`' `.trigger("click")` doesn't synthesize
|
|
93
|
+
this implicit focus side effect on its own — a test asserting click-to-close must explicitly
|
|
94
|
+
`.trigger("focusin")` before `.trigger("click")` to reproduce it, or it'll pass against the
|
|
95
|
+
buggy toggle-based version too.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Props reference
|
|
100
|
+
|
|
101
|
+
| Prop | Type | Default | Notes |
|
|
102
|
+
|------|------|---------|-------|
|
|
103
|
+
| `responsiveNavLinks` | `ResponsiveHeaderProp` (`{ [groupKey]: ResponsiveHeaderNavItem[] }`) | `{}` | Nav groups. Each item is either a link (`path`) or a dropdown (`childLinksTitle` + `childLinks`). A single group with no dropdowns is valid for simple sites. |
|
|
104
|
+
| `gapBetweenFirstAndSecondNav` | `number` | `12` | Pixel gap reserved between the first and second nav groups (also factored into the overflow-collapse width math). |
|
|
105
|
+
| `overflowDetailsSummaryIcons` | `Record<string, string>` | `{ more: "gravity-ui:ellipsis", burger: "gravity-ui:bars" }` | Icon names for the overflow button's two states: `more` shows when only *some* items collapsed, `burger` shows when `allowNavigationCollapse` is active (whole nav collapsed). |
|
|
106
|
+
| `collapseBreakpoint` | `number \| null` | `null` | A fixed pixel width below which the whole main nav collapses into the overflow burger, instead of the default per-item responsive collapse. |
|
|
107
|
+
| `collapseAtMainNavIntersection` | `boolean` | `false` | Like `collapseBreakpoint`, but the breakpoint is derived automatically from the main nav's own measured width rather than a fixed number. |
|
|
108
|
+
| `allowExpandOnGesture` | `boolean` | `true` | When `true`, hovering/focusing a dropdown summary opens it (in addition to click). When `false`, only click toggles it. |
|
|
109
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.navigation` element. |
|
|
110
|
+
|
|
111
|
+
## Slots
|
|
112
|
+
|
|
113
|
+
| Slot | Purpose |
|
|
114
|
+
|------|---------|
|
|
115
|
+
| `#secondaryNavigation` | Extra content rendered after the overflow burger button (e.g. a settings icon link). Only rendered when the slot is provided. |
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Public CSS token API
|
|
120
|
+
|
|
121
|
+
All tokens are read via `var(--token, default)` — see the full list in the component's own
|
|
122
|
+
`<style>` block comment. Highlights:
|
|
123
|
+
|
|
124
|
+
| Token | Default | Controls |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `--responsive-header-link-font-size` | `inherit` | Nav-link font-size. **Set this to a fixed value** — see the measurement-pipeline note above for why leaving it `inherit` from a fluid ancestor is a footgun. |
|
|
127
|
+
| `--responsive-header-link-color` | `inherit` | Link/summary text colour. |
|
|
128
|
+
| `--responsive-header-bg` / `--responsive-header-padding-*` / `--responsive-header-border*` | transparent / `0` / `none` | Root element theming. |
|
|
129
|
+
| `--responsive-header-overflow-btn-*` | various | Overflow burger button sizing/colour. |
|
|
130
|
+
| `--responsive-header-sub-nav-*` / `--responsive-header-overflow-nav-*` | various | Top-bar dropdown panel and overflow-panel container theming. |
|
|
131
|
+
| `--responsive-nav-decorator-indicator-color` / `--responsive-nav-decorator-hovered-*` | `currentColor` / inherits | The sliding active/hover indicator bar under the main nav. |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Usage example
|
|
136
|
+
|
|
137
|
+
```vue
|
|
138
|
+
<script setup lang="ts">
|
|
139
|
+
const responsiveNavLinks = {
|
|
140
|
+
firstNav: [
|
|
141
|
+
{ name: "Home", path: "/" },
|
|
142
|
+
{
|
|
143
|
+
name: "Components",
|
|
144
|
+
childLinksTitle: "UI Components",
|
|
145
|
+
childLinks: [{ name: "Buttons", path: "/forms/examples/buttons" }],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
secondNav: [{ name: "Contact", path: "/contact" }],
|
|
149
|
+
};
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<template>
|
|
153
|
+
<ResponsiveHeader :responsive-nav-links="responsiveNavLinks" :style-class-passthrough="['site-header-nav']">
|
|
154
|
+
<template #secondaryNavigation>
|
|
155
|
+
<NuxtLink to="/settings" aria-label="Settings">
|
|
156
|
+
<Icon name="material-symbols:settings-outline-rounded" />
|
|
157
|
+
</NuxtLink>
|
|
158
|
+
</template>
|
|
159
|
+
</ResponsiveHeader>
|
|
160
|
+
</template>
|
|
161
|
+
```
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SiteHeader Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`SiteHeader` composes the standard page-header trio into one drop-in component: `PageRow` (header row wrapper) + `SkipLinks` (a11y skip-to-content/footer anchors with a home-link slot) + `ResponsiveHeader` (the adaptive/overflow-collapsing nav with dropdowns). It replaces manually assembling those three in every consuming app's `layouts/default.vue`.
|
|
6
|
+
|
|
7
|
+
This is **not** a classic multi-column "mega menu" (large flyout panels with images/promo blocks) — it's the existing `ResponsiveHeader` nav (main links, dropdown sub-nav, overflow burger collapse) wrapped for reuse. A single flat nav group with no dropdowns works fine too (see the `SimpleFlatNav` story) — you don't need to use the dropdown/overflow behaviour to benefit from the composition.
|
|
8
|
+
|
|
9
|
+
Branding (logo/wordmark) is entirely consumer-authored via the `#branding` slot — `SiteHeader` has no opinion on brand markup or colours.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Props reference
|
|
14
|
+
|
|
15
|
+
| Prop | Type | Default | Notes |
|
|
16
|
+
|------|------|---------|-------|
|
|
17
|
+
| `responsiveNavLinks` | `ResponsiveHeaderProp` | `{}` | Forwarded to `ResponsiveHeader`. Keyed nav groups; each item is either a link (`path`) or a dropdown (`childLinksTitle` + `childLinks`). A single group with no dropdowns is valid for simple sites. |
|
|
18
|
+
| `gapBetweenFirstAndSecondNav` | `number` | `12` | Forwarded to `ResponsiveHeader`. |
|
|
19
|
+
| `overflowDetailsSummaryIcons` | `Record<string, string>` | `{ more: "gravity-ui:ellipsis", burger: "gravity-ui:bars" }` | Forwarded to `ResponsiveHeader`. |
|
|
20
|
+
| `collapseBreakpoint` | `number \| null` | `null` | Forwarded to `ResponsiveHeader`. |
|
|
21
|
+
| `collapseAtMainNavIntersection` | `boolean` | `false` | Forwarded to `ResponsiveHeader`. |
|
|
22
|
+
| `allowExpandOnGesture` | `boolean` | `true` | Forwarded to `ResponsiveHeader`. |
|
|
23
|
+
| `pageRowVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | Forwarded to the root `PageRow`'s `variant` prop. |
|
|
24
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes on the root `PageRow` — targets `--site-header-*` tokens (see `CONSUMER-STYLING.md`). |
|
|
25
|
+
| `navStyleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes on the nested `ResponsiveHeader` — targets its own `--responsive-header-*`/`--overflow-nav-*` tokens. |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Slots
|
|
30
|
+
|
|
31
|
+
| Slot | Purpose |
|
|
32
|
+
|------|---------|
|
|
33
|
+
| `#branding` | Logo/wordmark markup, rendered inside `SkipLinks`' `#homeLink` slot. Consumer supplies the `NuxtLink`/`NuxtImg`/text — `SiteHeader` doesn't wrap or style it. |
|
|
34
|
+
| `#secondaryNavigation` | Forwarded through to `ResponsiveHeader`'s own `#secondaryNavigation` slot (e.g. a settings icon link). Only rendered when provided. |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Usage examples
|
|
39
|
+
|
|
40
|
+
### Full nav with dropdowns (mega-menu-style)
|
|
41
|
+
|
|
42
|
+
```vue
|
|
43
|
+
<SiteHeader
|
|
44
|
+
:responsive-nav-links="responsiveNavLinks"
|
|
45
|
+
:style-class-passthrough="['header']"
|
|
46
|
+
:nav-style-class-passthrough="['site-header-nav']"
|
|
47
|
+
>
|
|
48
|
+
<template #branding>
|
|
49
|
+
<NuxtLink to="/" class="home-link">Brand</NuxtLink>
|
|
50
|
+
</template>
|
|
51
|
+
</SiteHeader>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Simple flat nav (few pages, no dropdowns)
|
|
55
|
+
|
|
56
|
+
```vue
|
|
57
|
+
<script setup lang="ts">
|
|
58
|
+
const responsiveNavLinks = {
|
|
59
|
+
main: [
|
|
60
|
+
{ name: "Home", path: "/" },
|
|
61
|
+
{ name: "About", path: "/about" },
|
|
62
|
+
{ name: "Services", path: "/services" },
|
|
63
|
+
{ name: "Contact", path: "/contact" },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<SiteHeader :responsive-nav-links="responsiveNavLinks" :style-class-passthrough="['header']">
|
|
70
|
+
<template #branding>
|
|
71
|
+
<NuxtLink to="/" class="home-link">Brand</NuxtLink>
|
|
72
|
+
</template>
|
|
73
|
+
</SiteHeader>
|
|
74
|
+
</template>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### With a secondary navigation slot
|
|
78
|
+
|
|
79
|
+
```vue
|
|
80
|
+
<SiteHeader :responsive-nav-links="responsiveNavLinks">
|
|
81
|
+
<template #branding>
|
|
82
|
+
<NuxtLink to="/" class="home-link">Brand</NuxtLink>
|
|
83
|
+
</template>
|
|
84
|
+
<template #secondaryNavigation>
|
|
85
|
+
<NuxtLink to="/settings" aria-label="Settings">
|
|
86
|
+
<Icon name="material-symbols:settings-outline-rounded" />
|
|
87
|
+
</NuxtLink>
|
|
88
|
+
</template>
|
|
89
|
+
</SiteHeader>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Migration notes
|
|
95
|
+
|
|
96
|
+
- Consuming layouts previously assembling `PageRow` + `SkipLinks` + `ResponsiveHeader` (or `TabNavigation`) by hand can switch to `SiteHeader` directly; the two `styleClassPassthrough` props map 1:1 onto the two independent passthrough hooks that pattern used.
|
|
97
|
+
- `TabNavigation`'s `NavItemData` shape (`{ main: [{ text, href, iconName, cssName }] }`) is **not** the same shape as `ResponsiveHeaderProp` (`{ groupKey: [{ name, path, childLinksTitle, childLinks }] }`) — migrating a `TabNavigation` consumer means reshaping the nav data, not just swapping the component. `TabNavigation`'s `navAlign` prop has no `SiteHeader` equivalent since `ResponsiveHeader`'s layout is fixed (branding column + nav column).
|
|
98
|
+
- Requires the consuming layout's main/footer regions to carry `id="main-content"` / `id="footer-content"` — same requirement as using `SkipLinks` directly.
|
package/.claude/skills/index.md
CHANGED
|
@@ -36,6 +36,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
36
36
|
├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
|
|
37
37
|
├── component-local-style-override.md — styleClassPassthrough + scoped style block for per-usage visual customisation
|
|
38
38
|
├── component-prop-driven-container-layout.md — vary CSS grid layout inside @container queries using data-* attribute selectors
|
|
39
|
+
├── page-transitions.md — pageTransition/layoutTransition setup; the self-wrapped <NuxtLayout> + layout:false anti-pattern that pulls the header/nav into the transition boundary; fade CSS
|
|
39
40
|
├── css-nesting-conventions.md — native CSS nesting rules: why &__child Sass BEM concatenation silently breaks, correct patterns
|
|
40
41
|
├── css-grid-max-width-gutters.md — cap a centre grid column width by growing gutters, with start/center alignment variants
|
|
41
42
|
├── css-animation-utilities.md — scroll-driven animation utility classes: scroller-x (carousel), entry-zoom-reveal, entry-slide-in, entry-exit-blur, auto-rotate
|
|
@@ -98,7 +99,11 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
98
99
|
├── carousel-flip.md — CarouselFlip: FLIP-animated carousel, carouselDataIds slot API, buttonLayout variants (sides/controls-flanking/controls-grouped-right/overlay), CSS tokens
|
|
99
100
|
├── samaritan-prompt-mixed.md — SamaritanPromptMixed: animated text prompt, typewriter/word-pulse effects, MessageConfig API, aria-live accessibility, CSS tokens
|
|
100
101
|
├── display-toast.md — DisplayToast (standalone v-model) + DisplayToastProvider + useToastQueue (app-wide queue): stacking, FLIP dismiss, maxVisible, SemanticTheme × 4, masked SVG glass variant
|
|
101
|
-
|
|
102
|
+
├── display-prompt.md — DisplayPrompt: inline notification banner, SemanticTheme × 4, local vs parent-controlled dismiss, outlined modifier, CSS token override
|
|
103
|
+
├── expanding-panel-classic.md — ExpandingPanelClassic: grid-template-rows animation (no Baseline-2025 dependency), same API as ExpandingPanel, cross-browser animation parity trade-off
|
|
104
|
+
├── site-header.md — SiteHeader: PageRow + SkipLinks + ResponsiveHeader composition, #branding/#secondaryNavigation slots, dual styleClassPassthrough hooks
|
|
105
|
+
├── responsive-header.md — ResponsiveHeader: overflow-collapsing adaptive nav, measurement-pipeline gotchas (unsized icons, vw font-size drift), full CSS token API
|
|
106
|
+
└── navigation-items.md — NavigationItems: internal overflow-panel renderer for ResponsiveHeader, complement-visibility logic, not used standalone
|
|
102
107
|
```
|
|
103
108
|
|
|
104
109
|
## Skill file template
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Page Transitions
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`pageTransition`/`layoutTransition` in `nuxt.config.ts` name the Vue `<Transition>` that wraps
|
|
6
|
+
`<NuxtPage>`/`<NuxtLayout>`. Getting the app's template structure wrong here means the whole page —
|
|
7
|
+
header, nav, footer included — fades/animates on every navigation, not just the routed content.
|
|
8
|
+
This is silent: no build error, no warning, it just looks visually wrong ("the header flickers/
|
|
9
|
+
fades too") once a transition actually has a visible duration.
|
|
10
|
+
|
|
11
|
+
## The anti-pattern
|
|
12
|
+
|
|
13
|
+
Each page self-wraps its own content in `<NuxtLayout name="default">` and sets
|
|
14
|
+
`definePageMeta({ layout: false })` to stop Nuxt's automatic layout wrapping:
|
|
15
|
+
|
|
16
|
+
```vue
|
|
17
|
+
<!-- pages/contact.vue — WRONG -->
|
|
18
|
+
<template>
|
|
19
|
+
<div>
|
|
20
|
+
<NuxtLayout name="default">
|
|
21
|
+
<div class="hero">...</div>
|
|
22
|
+
</NuxtLayout>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
definePageMeta({ layout: false })
|
|
28
|
+
</script>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This often isn't a deliberate choice — it's commonly left over from a Nuxt 3→4 directory-structure
|
|
32
|
+
migration, or copied from a page that had a real reason to opt out of the layout (rare). Once it's
|
|
33
|
+
in one page it tends to get copy-pasted to every page.
|
|
34
|
+
|
|
35
|
+
**Why it's wrong**: `<NuxtLayout name="default">` here is INSIDE the page component's own render
|
|
36
|
+
tree. Since `<NuxtPage>` (wrapped in the `pageTransition`) renders the matched page component as
|
|
37
|
+
its child, the layout — including the header, nav, and footer defined in `layouts/default.vue` —
|
|
38
|
+
becomes part of the transitioning subtree. Every navigation unmounts and remounts the *entire*
|
|
39
|
+
layout, and it visually fades/animates along with the page content.
|
|
40
|
+
|
|
41
|
+
## The correct pattern
|
|
42
|
+
|
|
43
|
+
Don't set `layout: false`. Don't wrap page content in `<NuxtLayout>` inside the page itself. Let
|
|
44
|
+
Nuxt's default `app.vue` (no need to write your own) apply the layout once, persistently, at the
|
|
45
|
+
app level:
|
|
46
|
+
|
|
47
|
+
```vue
|
|
48
|
+
<!-- pages/contact.vue — CORRECT -->
|
|
49
|
+
<template>
|
|
50
|
+
<div class="contact-page-content">
|
|
51
|
+
<div class="hero">...</div>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<script setup lang="ts">
|
|
56
|
+
// no definePageMeta({ layout: false }) needed
|
|
57
|
+
</script>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`layouts/default.vue` itself needs no changes — it already receives page content via its
|
|
61
|
+
`<slot name="default">` (or plain `<slot>`), which is exactly what Nuxt's automatic wrapping
|
|
62
|
+
provides.
|
|
63
|
+
|
|
64
|
+
With this structure, `pageTransition` only ever wraps the page component's own root element
|
|
65
|
+
(`.contact-page-content` etc.) — the header/nav/footer live in the persistent `<NuxtLayout>`
|
|
66
|
+
*outside* `<NuxtPage>`, so they never unmount, never fade, and stay visually static across every
|
|
67
|
+
navigation. Verified via Playwright: with a real transition duration in place, the header DOM node
|
|
68
|
+
and its computed `opacity` stay unchanged for the header across the whole navigation, while only
|
|
69
|
+
the page-content element's class list cycles through
|
|
70
|
+
`page-leave-from` → `page-leave-active`/`page-leave-to` → `page-enter-from`/`page-enter-active` →
|
|
71
|
+
`page-enter-to`.
|
|
72
|
+
|
|
73
|
+
## Migrating an app off the anti-pattern
|
|
74
|
+
|
|
75
|
+
For every page:
|
|
76
|
+
|
|
77
|
+
1. Remove `definePageMeta({ layout: false })` (delete the whole call if that was its only option).
|
|
78
|
+
2. Remove the `<NuxtLayout name="default">`/`</NuxtLayout>` wrapper tags from the template,
|
|
79
|
+
de-indenting the content that was inside them by one level. Keep the page's own outer element
|
|
80
|
+
(add a scoping class here if it didn't have one — see the body-class warning in
|
|
81
|
+
`component-local-style-override.md`, since this is also the right place to move any
|
|
82
|
+
`bodyAttrs.class`-scoped local `<style>` to).
|
|
83
|
+
|
|
84
|
+
Verify with `npx tsc --noEmit` (or `nuxt build`) afterward — a stray extra `</NuxtLayout>` or
|
|
85
|
+
mismatched brace is easy to leave behind when doing this across many files by hand.
|
|
86
|
+
|
|
87
|
+
## Giving the transition an actual CSS effect
|
|
88
|
+
|
|
89
|
+
`pageTransition: { name: "page", mode: "out-in" }` alone does nothing visible — Vue's `<Transition>`
|
|
90
|
+
needs matching CSS classes or it's an instant, unanimated swap. A simple fade:
|
|
91
|
+
|
|
92
|
+
```css
|
|
93
|
+
.page-enter-active,
|
|
94
|
+
.page-leave-active {
|
|
95
|
+
transition: opacity 0.2s ease;
|
|
96
|
+
}
|
|
97
|
+
.page-enter-from,
|
|
98
|
+
.page-leave-to {
|
|
99
|
+
opacity: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@media (prefers-reduced-motion: reduce) {
|
|
103
|
+
.page-enter-active,
|
|
104
|
+
.page-leave-active {
|
|
105
|
+
transition: none;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`mode: "out-in"` fades the old page fully out before the new one fades in — no overlap, no need for
|
|
111
|
+
`position: absolute` tricks during the transition.
|
|
112
|
+
|
|
113
|
+
## See also
|
|
114
|
+
|
|
115
|
+
- `component-local-style-override.md` — the body-class race this pattern also exposes, and the
|
|
116
|
+
`{name}-page-content` naming convention used above
|
|
@@ -17,6 +17,18 @@ imported before or after this layer's `main.css` in `nuxt.config.ts`'s `css` arr
|
|
|
17
17
|
always wins either way. `theming-override-default.md`/`theming-partial-override.md` already rely
|
|
18
18
|
on this same mechanism for colour tokens; it applies identically here.
|
|
19
19
|
|
|
20
|
+
> **⚠️ Do not "fix" this by wrapping your override in `@layer consumer`** to mirror the library's
|
|
21
|
+
> own `@layer reset, colours, theming, form-tokens, typography, a11y, components, utilities,
|
|
22
|
+
> consumer;` stack — it looks like the intended slot but is unsafe in practice. Cascade layer
|
|
23
|
+
> priority is fixed by whichever layer name is first referenced *anywhere in the document*, not by
|
|
24
|
+
> its position in that pre-declaration. Nuxt/Nitro inlines many small per-component/per-page CSS
|
|
25
|
+
> chunks as `<style>` tags directly in `<head>` for SSR performance — if an inlined chunk declares
|
|
26
|
+
> `@layer consumer` before the library's main stylesheet (which carries the master statement) loads
|
|
27
|
+
> via its `<link>`, `consumer` gets registered first and pushed to the *lowest* priority instead of
|
|
28
|
+
> the highest, so `@layer form-tokens`/`theming` silently win instead, with no build error.
|
|
29
|
+
> Confirmed by reproducing against a real `npm run build` + preview, not just `nuxt dev`. Stay
|
|
30
|
+
> unlayered — that's what makes the guarantee above hold.
|
|
31
|
+
|
|
20
32
|
So: to change one geometry value, redeclare just that one custom property in your app's own CSS
|
|
21
33
|
(e.g. `app/assets/styles/setup/03.theming/_default.css`, or a new file imported the same way).
|
|
22
34
|
There is no dependency between these tokens — overriding `--button-padding-inline` alone does not
|
|
@@ -16,6 +16,20 @@ For the full architecture see `theming-colour-ramps.md`.
|
|
|
16
16
|
|
|
17
17
|
## Steps
|
|
18
18
|
|
|
19
|
+
> **⚠️ Keep every file in this skill unlayered — never wrap them in `@layer consumer` (or any
|
|
20
|
+
> named layer)**, even though the library reserves `consumer` as the last name in its own
|
|
21
|
+
> `@layer reset, colours, theming, form-tokens, typography, a11y, components, utilities, consumer;`
|
|
22
|
+
> stack. Cascade layer priority is fixed by whichever layer name is first referenced *anywhere in
|
|
23
|
+
> the document*, not by its position in that pre-declaration. Nuxt/Nitro inlines many small
|
|
24
|
+
> per-component/per-page CSS chunks as `<style>` tags directly in `<head>` for SSR performance —
|
|
25
|
+
> if one of those happens to declare `@layer consumer` before the library's main stylesheet (which
|
|
26
|
+
> carries the master `@layer` statement) loads via its `<link>`, `consumer` gets registered first
|
|
27
|
+
> and pushed to the *lowest* priority, so `@layer theming` silently wins instead — e.g. every
|
|
28
|
+
> button reverting to the library's default blue with no build error. Confirmed by reproducing
|
|
29
|
+
> against a real `npm run build` + preview, not just `nuxt dev`. Unlayered CSS always wins over
|
|
30
|
+
> every named layer regardless of document order, which is what makes the "wins the cascade
|
|
31
|
+
> automatically" claim below actually true.
|
|
32
|
+
|
|
19
33
|
### 1. Create `ramps.config.mjs` in your project root
|
|
20
34
|
|
|
21
35
|
Define the palettes you want. You can add brand-new ones, reuse a built-in name to replace
|
|
@@ -14,6 +14,23 @@ All library tokens are declared inside `@layer theming`. Any CSS written outside
|
|
|
14
14
|
automatically — no `!important` or specificity tricks required. Override files just need to load
|
|
15
15
|
after the layer styles.
|
|
16
16
|
|
|
17
|
+
> **⚠️ Never wrap your override CSS in a named `@layer` (e.g. `@layer consumer`), even though the
|
|
18
|
+
> library reserves `consumer` as the last name in its own `@layer reset, colours, theming,
|
|
19
|
+
> form-tokens, typography, a11y, components, utilities, consumer;` stack and it looks like the
|
|
20
|
+
> "correct" slot to write into.
|
|
21
|
+
>
|
|
22
|
+
> Cascade layer *priority* order is fixed by whichever layer name is first referenced anywhere in
|
|
23
|
+
> the document — not by where it sits in a later `@layer a, b, c;` pre-declaration. Nuxt/Nitro
|
|
24
|
+
> inlines many small per-component/per-page CSS chunks as `<style>` tags directly in `<head>` for
|
|
25
|
+
> SSR performance. If an inlined chunk declares `@layer consumer { ... }` before the library's
|
|
26
|
+
> main stylesheet (which carries the master `@layer` statement) has loaded via its `<link>`,
|
|
27
|
+
> `consumer` gets registered *first* — pushed to the *lowest* priority instead of the intended
|
|
28
|
+
> highest — and `@layer theming`/`@layer components` silently win instead, with no build error.
|
|
29
|
+
> This reproduces in a real `npm run build` + preview, not just `nuxt dev`.
|
|
30
|
+
>
|
|
31
|
+
> Keep all consumer-app override CSS **unlayered**. Unlayered CSS always wins over every named
|
|
32
|
+
> layer regardless of document/inlining order, which is what "wins automatically" above relies on.
|
|
33
|
+
|
|
17
34
|
## Semantic slots — the tokens to override
|
|
18
35
|
|
|
19
36
|
All themed components share an 11-slot vocabulary (see `_theme-slots.css`). Overriding these
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"SRCDEV AccordianCore Basic": {
|
|
3
|
+
"description": "AccordianCore grouped into an exclusive-open accordion (shared name)",
|
|
4
|
+
"scope": "vue,html",
|
|
5
|
+
"body": [
|
|
6
|
+
"<AccordianCore name=\"$1faq\" :item-count=\"$2\">",
|
|
7
|
+
" <template #accordian-0-summary>",
|
|
8
|
+
" <span>$3Question one?</span>",
|
|
9
|
+
" </template>",
|
|
10
|
+
" <template #accordian-0-content>",
|
|
11
|
+
" <p>$4Answer one.</p>",
|
|
12
|
+
" </template>",
|
|
13
|
+
"",
|
|
14
|
+
" <template #accordian-1-summary>",
|
|
15
|
+
" <span>$5Question two?</span>",
|
|
16
|
+
" </template>",
|
|
17
|
+
" <template #accordian-1-content>",
|
|
18
|
+
" <p>$6Answer two.</p>",
|
|
19
|
+
" </template>",
|
|
20
|
+
"</AccordianCore>"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"SRCDEV AccordianCore Classic Variant": {
|
|
24
|
+
"description": "AccordianCore using ExpandingPanelClassic panels (animation runs in every browser)",
|
|
25
|
+
"scope": "vue,html",
|
|
26
|
+
"body": [
|
|
27
|
+
"<AccordianCore name=\"$1faq\" :item-count=\"$2\" variant=\"classic\">",
|
|
28
|
+
" <template #accordian-0-summary>",
|
|
29
|
+
" <span>$3Question one?</span>",
|
|
30
|
+
" </template>",
|
|
31
|
+
" <template #accordian-0-content>",
|
|
32
|
+
" <p>$4Answer one.</p>",
|
|
33
|
+
" </template>",
|
|
34
|
+
"",
|
|
35
|
+
" <template #accordian-1-summary>",
|
|
36
|
+
" <span>$5Question two?</span>",
|
|
37
|
+
" </template>",
|
|
38
|
+
" <template #accordian-1-content>",
|
|
39
|
+
" <p>$6Answer two.</p>",
|
|
40
|
+
" </template>",
|
|
41
|
+
"</AccordianCore>"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"SRCDEV AccordianCore Programmatic Slots": {
|
|
45
|
+
"description": "AccordianCore with slots generated from an items array via v-for",
|
|
46
|
+
"scope": "vue,html",
|
|
47
|
+
"body": [
|
|
48
|
+
"<AccordianCore :item-count=\"$1items.length\">",
|
|
49
|
+
" <template v-for=\"(item, i) in $1items\" #[`accordian-\\${i}-summary`] :key=\"i\">",
|
|
50
|
+
" <span>{{ item.$2title }}</span>",
|
|
51
|
+
" </template>",
|
|
52
|
+
" <template v-for=\"(item, i) in $1items\" #[`accordian-\\${i}-content`] :key=\"i\">",
|
|
53
|
+
" <p>{{ item.$3body }}</p>",
|
|
54
|
+
" </template>",
|
|
55
|
+
"</AccordianCore>"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"SRCDEV AccordianCore CSS Override": {
|
|
59
|
+
"description": "CSS override scaffold for AccordianCore panel-level styling",
|
|
60
|
+
"scope": "css",
|
|
61
|
+
"body": [
|
|
62
|
+
".$1my-accordian {",
|
|
63
|
+
" .display-accordian {",
|
|
64
|
+
" /* max-width: none; */",
|
|
65
|
+
"",
|
|
66
|
+
" .accordian-item.expanding-panel {",
|
|
67
|
+
" /* border-block-end: 1px solid currentColor; */",
|
|
68
|
+
" /* border-radius: $2; */",
|
|
69
|
+
" }",
|
|
70
|
+
" }",
|
|
71
|
+
"}"
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
}
|