srcdev-nuxt-components 9.1.48 → 9.1.49
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/composable-anchor-scroll.md +110 -28
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +33 -2
- package/app/composables/useAnchorScroll.ts +16 -3
- package/app/composables/useNavCollapse.ts +5 -1
- package/app/layouts/default.vue +1 -0
- package/app/pages/ui/anchor-scroll-tab-navigation.vue +180 -0
- package/app/pages/ui/anchor-scroll.vue +11 -1
- package/package.json +1 -1
|
@@ -20,7 +20,8 @@ Auto-imported by Nuxt — **do not create a local copy**.
|
|
|
20
20
|
|
|
21
21
|
| Option | Type | Default | Description |
|
|
22
22
|
|---|---|---|---|
|
|
23
|
-
| `offset` | `number
|
|
23
|
+
| `offset` | `number | (() => number)` | `0` | Pixels subtracted from the final scroll position. Pass a getter function to read a sticky element's height at scroll time rather than at composable init. |
|
|
24
|
+
| `offsetElement` | `Ref<HTMLElement | null>` | — | Convenience alternative to `offset`. Reads `element.offsetHeight` at scroll time. Takes priority over `offset` when both are supplied. |
|
|
24
25
|
|
|
25
26
|
### Returns
|
|
26
27
|
|
|
@@ -28,17 +29,69 @@ Auto-imported by Nuxt — **do not create a local copy**.
|
|
|
28
29
|
|---|---|---|
|
|
29
30
|
| `handleNavClick` | `(event: MouseEvent, href: string) => void` | Attach to click handlers. No-ops silently for non-`#` hrefs — safe on all links. |
|
|
30
31
|
| `scrollToAnchor` | `(hash: string) => void` | Scroll programmatically. Accepts `"#section"` or `"section"`. Respects motion preference and offset. |
|
|
32
|
+
| `activeHash` | `Ref<string>` | Reactive ref tracking the currently active hash. Starts `""` on server and after hydration is set from `window.location.hash`. Updated on every `handleNavClick` call. Use this to drive `is-active` classes — **do not use `route.hash`**, which is not updated by `history.pushState`. |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Active state
|
|
37
|
+
|
|
38
|
+
`activeHash` is the correct way to drive active-state styling on anchor nav links.
|
|
39
|
+
|
|
40
|
+
**Why not `route.hash`?** `handleNavClick` calls `history.pushState` directly (to avoid triggering Vue Router's scroll behaviour). `pushState` does not update Vue Router's reactive `route.hash`, so `route.hash` stays stale after clicks.
|
|
41
|
+
|
|
42
|
+
**Hydration safety:** `activeHash` is initialised to `""` on both server and client so the SSR-rendered HTML always matches the pre-mount client vdom. The real hash is applied in `onMounted`, after hydration, to avoid mismatches.
|
|
43
|
+
|
|
44
|
+
**Default active item:** `activeHash` is empty until `onMounted` fires. If no hash is in the URL, set a default in the component's own `onMounted` (which runs after the composable's `onMounted`):
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offset: 64 });
|
|
48
|
+
|
|
49
|
+
onMounted(() => {
|
|
50
|
+
// Default to first section when no hash is in the URL
|
|
51
|
+
if (!activeHash.value && sections[0]) activeHash.value = `#${sections[0].id}`;
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Binding active state in a template
|
|
56
|
+
|
|
57
|
+
```vue
|
|
58
|
+
<a
|
|
59
|
+
v-for="section in sections"
|
|
60
|
+
:key="section.id"
|
|
61
|
+
:href="`#${section.id}`"
|
|
62
|
+
:class="{ 'is-active': `#${section.id}` === activeHash }"
|
|
63
|
+
@click="(e) => handleNavClick(e, `#${section.id}`)"
|
|
64
|
+
>{{ section.label }}</a>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Active state in TabNavigation
|
|
68
|
+
|
|
69
|
+
`TabNavigation` handles `activeHash` internally for hash nav items. No extra work needed —
|
|
70
|
+
just pass anchor hrefs in `navItemData` and the active indicator moves automatically on click
|
|
71
|
+
and on initial load (defaulting to the first hash item when the URL has no hash).
|
|
31
72
|
|
|
32
73
|
---
|
|
33
74
|
|
|
34
75
|
## Usage patterns
|
|
35
76
|
|
|
36
|
-
### 1. TabNavigation with
|
|
77
|
+
### 1. TabNavigation with a sticky site header
|
|
37
78
|
|
|
38
|
-
`TabNavigation`
|
|
39
|
-
|
|
79
|
+
`TabNavigation` accepts an `anchorScrollOffset` prop that is passed directly to `useAnchorScroll`.
|
|
80
|
+
Pass a getter so the live header height is read at scroll time — this stays correct if the
|
|
81
|
+
header resizes on different viewports.
|
|
82
|
+
|
|
83
|
+
```vue
|
|
84
|
+
<header ref="headerRef">
|
|
85
|
+
<TabNavigation
|
|
86
|
+
:nav-item-data="navItemData"
|
|
87
|
+
:anchor-scroll-offset="() => headerRef?.offsetHeight ?? 0"
|
|
88
|
+
/>
|
|
89
|
+
</header>
|
|
90
|
+
```
|
|
40
91
|
|
|
41
92
|
```ts
|
|
93
|
+
const headerRef = ref<HTMLElement | null>(null);
|
|
94
|
+
|
|
42
95
|
const navItemData = {
|
|
43
96
|
main: [
|
|
44
97
|
{ text: "About", href: "#about" },
|
|
@@ -49,39 +102,55 @@ const navItemData = {
|
|
|
49
102
|
};
|
|
50
103
|
```
|
|
51
104
|
|
|
52
|
-
|
|
53
|
-
|
|
105
|
+
**CSS alternative** — if you want all anchor links site-wide to respect the sticky header
|
|
106
|
+
(not just the ones inside `TabNavigation`), add `scroll-padding-top` to `html` instead and
|
|
107
|
+
skip the prop:
|
|
108
|
+
|
|
109
|
+
```css
|
|
110
|
+
/* ─ app/assets/styles/setup/01.config/_head.css ─ */
|
|
111
|
+
html {
|
|
112
|
+
scroll-padding-top: var(--sticky-header-height, 64px);
|
|
113
|
+
}
|
|
54
114
|
```
|
|
55
115
|
|
|
56
|
-
|
|
57
|
-
|
|
116
|
+
```css
|
|
117
|
+
/* ─ app/assets/styles/main.css ─ */
|
|
118
|
+
:root {
|
|
119
|
+
--sticky-header-height: 64px; /* adjust to match actual header height */
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`scrollIntoView` respects `scroll-padding-top`, so this works with the no-offset code path.
|
|
58
124
|
|
|
59
125
|
---
|
|
60
126
|
|
|
61
|
-
### 2. Sticky section nav with dynamic offset
|
|
127
|
+
### 2. Sticky section nav with dynamic offset (using offsetElement)
|
|
62
128
|
|
|
63
|
-
The
|
|
64
|
-
|
|
65
|
-
is read at scroll time — this stays correct if the bar resizes (e.g. on viewport changes).
|
|
129
|
+
The `offsetElement` option is the clearest way to derive an offset from an element ref.
|
|
130
|
+
It reads `offsetHeight` at scroll time, so resize changes are always captured.
|
|
66
131
|
|
|
67
132
|
```ts
|
|
68
133
|
const stickyNavRef = ref<HTMLElement | null>(null);
|
|
69
134
|
|
|
70
|
-
const { handleNavClick } = useAnchorScroll({
|
|
71
|
-
|
|
135
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offsetElement: stickyNavRef });
|
|
136
|
+
|
|
137
|
+
onMounted(() => {
|
|
138
|
+
if (!activeHash.value && sections[0]) activeHash.value = `#${sections[0].id}`;
|
|
72
139
|
});
|
|
73
140
|
```
|
|
74
141
|
|
|
75
142
|
```vue
|
|
76
143
|
<nav ref="stickyNavRef" class="sticky-section-nav">
|
|
77
|
-
<a
|
|
78
|
-
|
|
79
|
-
|
|
144
|
+
<a
|
|
145
|
+
v-for="section in sections"
|
|
146
|
+
:key="section.id"
|
|
147
|
+
:href="`#${section.id}`"
|
|
148
|
+
:class="{ 'is-active': `#${section.id}` === activeHash }"
|
|
149
|
+
@click="(e) => handleNavClick(e, `#${section.id}`)"
|
|
150
|
+
>{{ section.label }}</a>
|
|
80
151
|
</nav>
|
|
81
152
|
|
|
82
|
-
<section id="
|
|
83
|
-
<section id="pricing">…</section>
|
|
84
|
-
<section id="contact">…</section>
|
|
153
|
+
<section v-for="section in sections" :key="section.id" :id="section.id">…</section>
|
|
85
154
|
```
|
|
86
155
|
|
|
87
156
|
CSS to make the nav sticky:
|
|
@@ -102,7 +171,7 @@ Same pattern as above — useful for terms, privacy policy, or documentation pag
|
|
|
102
171
|
sidebar links to in-document sections.
|
|
103
172
|
|
|
104
173
|
```ts
|
|
105
|
-
const { handleNavClick } = useAnchorScroll({ offset: 24 }); // fixed header height
|
|
174
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offset: 24 }); // fixed header height
|
|
106
175
|
```
|
|
107
176
|
|
|
108
177
|
```vue
|
|
@@ -110,6 +179,7 @@ const { handleNavClick } = useAnchorScroll({ offset: 24 }); // fixed header heig
|
|
|
110
179
|
<nav>
|
|
111
180
|
<a v-for="section in termsSections" :key="section.id"
|
|
112
181
|
:href="`#${section.id}`"
|
|
182
|
+
:class="{ 'is-active': `#${section.id}` === activeHash }"
|
|
113
183
|
@click="(e) => handleNavClick(e, `#${section.id}`)">
|
|
114
184
|
{{ section.title }}
|
|
115
185
|
</a>
|
|
@@ -156,10 +226,10 @@ onMounted(() => {
|
|
|
156
226
|
|
|
157
227
|
## How offset works
|
|
158
228
|
|
|
159
|
-
Without `offset`, the composable calls `el.scrollIntoView({ behavior, block: "start" })` —
|
|
229
|
+
Without `offset` or `offsetElement`, the composable calls `el.scrollIntoView({ behavior, block: "start" })` —
|
|
160
230
|
the element's top edge aligns with the viewport top.
|
|
161
231
|
|
|
162
|
-
With
|
|
232
|
+
With an offset, it uses `window.scrollTo` with a calculated position:
|
|
163
233
|
|
|
164
234
|
```
|
|
165
235
|
top = el.getBoundingClientRect().top + window.scrollY - offset
|
|
@@ -168,16 +238,19 @@ top = el.getBoundingClientRect().top + window.scrollY - offset
|
|
|
168
238
|
This shifts the final resting position downward by `offset` pixels, so a sticky bar of that
|
|
169
239
|
height does not overlap the section heading.
|
|
170
240
|
|
|
171
|
-
|
|
241
|
+
**`offsetElement` vs `offset` getter:**
|
|
172
242
|
|
|
173
243
|
```ts
|
|
174
|
-
// ✅
|
|
175
|
-
const { handleNavClick } = useAnchorScroll({
|
|
244
|
+
// ✅ offsetElement — reads offsetHeight at click time, no boilerplate
|
|
245
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offsetElement: navRef });
|
|
246
|
+
|
|
247
|
+
// ✅ offset getter — equivalent, useful when the offset is derived from more than one element
|
|
248
|
+
const { handleNavClick, activeHash } = useAnchorScroll({
|
|
176
249
|
offset: () => navRef.value?.offsetHeight ?? 0,
|
|
177
250
|
});
|
|
178
251
|
|
|
179
|
-
// ✗
|
|
180
|
-
const { handleNavClick } = useAnchorScroll({
|
|
252
|
+
// ✗ Static offset — stale if the bar changes height later
|
|
253
|
+
const { handleNavClick, activeHash } = useAnchorScroll({
|
|
181
254
|
offset: navRef.value?.offsetHeight ?? 0,
|
|
182
255
|
});
|
|
183
256
|
```
|
|
@@ -195,10 +268,19 @@ When `window.matchMedia("(prefers-reduced-motion: reduce)").matches` is `true`:
|
|
|
195
268
|
No configuration needed — the check happens at call time, so toggling the OS preference mid-session
|
|
196
269
|
takes effect immediately on the next click.
|
|
197
270
|
|
|
271
|
+
> **Debugging tip:** If clicks produce an instant jump instead of smooth scroll, check whether
|
|
272
|
+
> "Reduce Motion" is enabled in your OS accessibility settings (macOS: System Preferences →
|
|
273
|
+
> Accessibility → Display → Reduce Motion). This is intentional behaviour, not a bug.
|
|
274
|
+
|
|
198
275
|
---
|
|
199
276
|
|
|
200
277
|
## Notes
|
|
201
278
|
|
|
279
|
+
- **`activeHash` vs `route.hash`** — always use `activeHash` for active-state classes. `route.hash`
|
|
280
|
+
is not updated by `history.pushState` and will be stale after clicks.
|
|
281
|
+
- **Hydration mismatches** — `activeHash` is always `""` at render time; the real value is set in
|
|
282
|
+
`onMounted`. Never initialise it from `window.location.hash` in setup — that causes a server/client
|
|
283
|
+
class mismatch.
|
|
202
284
|
- **`history.pushState`** — `handleNavClick` pushes the hash into the URL so the back button and
|
|
203
285
|
deep links work correctly. This runs after `preventDefault` stops Vue Router from navigating,
|
|
204
286
|
so the URL stays in sync without triggering a router scroll.
|
|
@@ -19,10 +19,22 @@
|
|
|
19
19
|
v-for="item in navItemData.main"
|
|
20
20
|
:key="item.href"
|
|
21
21
|
:data-href="item.href"
|
|
22
|
-
:class="[item.cssName, { 'is-active': isActiveItem(item.href), 'is-hovered': hoveredItemHref === item.href }]"
|
|
22
|
+
:class="[item.cssName, { 'is-active': item.href?.startsWith('#') ? item.href === activeHash : isActiveItem(item.href), 'is-hovered': hoveredItemHref === item.href }]"
|
|
23
23
|
@mouseenter="hoveredItemHref = item.href ?? null"
|
|
24
24
|
>
|
|
25
|
+
<!-- Plain <a> for hash links — keeps Vue Router out of the smooth-scroll path -->
|
|
26
|
+
<a
|
|
27
|
+
v-if="item.href?.startsWith('#')"
|
|
28
|
+
:href="item.href"
|
|
29
|
+
class="tab-nav-link"
|
|
30
|
+
data-nav-item
|
|
31
|
+
@click="(e) => item.href && handleNavClick(e, item.href)"
|
|
32
|
+
>
|
|
33
|
+
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
34
|
+
{{ item.text }}
|
|
35
|
+
</a>
|
|
25
36
|
<NuxtLink
|
|
37
|
+
v-else
|
|
26
38
|
:href="item.href"
|
|
27
39
|
:external="item.isExternal || undefined"
|
|
28
40
|
class="tab-nav-link"
|
|
@@ -74,7 +86,17 @@
|
|
|
74
86
|
<div class="tab-nav-panel-inner">
|
|
75
87
|
<ul class="tab-nav-panel-list">
|
|
76
88
|
<li v-for="item in navItemData.main" :key="item.href" :class="item.cssName">
|
|
89
|
+
<a
|
|
90
|
+
v-if="item.href?.startsWith('#')"
|
|
91
|
+
:href="item.href"
|
|
92
|
+
class="tab-nav-panel-link"
|
|
93
|
+
@click="(e) => { item.href && handleNavClick(e, item.href); closeMenu(); }"
|
|
94
|
+
>
|
|
95
|
+
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
96
|
+
{{ item.text }}
|
|
97
|
+
</a>
|
|
77
98
|
<NuxtLink
|
|
99
|
+
v-else
|
|
78
100
|
:href="item.href"
|
|
79
101
|
:external="item.isExternal || undefined"
|
|
80
102
|
class="tab-nav-panel-link"
|
|
@@ -97,17 +119,26 @@ interface Props {
|
|
|
97
119
|
navItemData: NavItemData;
|
|
98
120
|
navAlign?: "left" | "center" | "right";
|
|
99
121
|
styleClassPassthrough?: string | string[];
|
|
122
|
+
anchorScrollOffset?: number | (() => number);
|
|
100
123
|
}
|
|
101
124
|
|
|
102
125
|
const props = withDefaults(defineProps<Props>(), {
|
|
103
126
|
navAlign: "left",
|
|
104
127
|
styleClassPassthrough: () => [],
|
|
128
|
+
anchorScrollOffset: undefined,
|
|
105
129
|
});
|
|
106
130
|
|
|
107
131
|
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu } =
|
|
108
132
|
useNavCollapse("tab-nav-loaded");
|
|
109
133
|
|
|
110
|
-
const { handleNavClick } = useAnchorScroll();
|
|
134
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offset: props.anchorScrollOffset });
|
|
135
|
+
|
|
136
|
+
onMounted(() => {
|
|
137
|
+
if (!activeHash.value) {
|
|
138
|
+
const firstHashItem = props.navItemData.main?.find((item) => item.href?.startsWith("#"));
|
|
139
|
+
if (firstHashItem?.href) activeHash.value = firstHashItem.href;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
111
142
|
|
|
112
143
|
// ─── Animation gate — disables indicator transitions during route changes ────
|
|
113
144
|
// Starts true: CSS anchor positioning resolves before first paint so there is
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
interface UseAnchorScrollOptions {
|
|
2
2
|
offset?: number | (() => number);
|
|
3
|
+
offsetElement?: Ref<HTMLElement | null>;
|
|
3
4
|
}
|
|
4
5
|
|
|
5
6
|
export const useAnchorScroll = (options: UseAnchorScrollOptions = {}) => {
|
|
6
|
-
const { offset = 0 } = options;
|
|
7
|
+
const { offset = 0, offsetElement } = options;
|
|
7
8
|
|
|
8
9
|
const prefersReducedMotion = (): boolean => {
|
|
9
10
|
if (import.meta.server) return false;
|
|
10
11
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
const resolveOffset = (): number =>
|
|
14
|
+
const resolveOffset = (): number => {
|
|
15
|
+
if (offsetElement) return offsetElement.value?.offsetHeight ?? 0;
|
|
16
|
+
return typeof offset === "function" ? offset() : offset;
|
|
17
|
+
};
|
|
14
18
|
|
|
15
19
|
const scrollToAnchor = (hash: string): void => {
|
|
16
20
|
if (import.meta.server) return;
|
|
@@ -29,6 +33,14 @@ export const useAnchorScroll = (options: UseAnchorScrollOptions = {}) => {
|
|
|
29
33
|
}
|
|
30
34
|
};
|
|
31
35
|
|
|
36
|
+
// Starts empty so server and client render identically (no hydration mismatch).
|
|
37
|
+
// onMounted sets the real value after hydration, client-only.
|
|
38
|
+
const activeHash = ref("");
|
|
39
|
+
|
|
40
|
+
onMounted(() => {
|
|
41
|
+
activeHash.value = window.location.hash;
|
|
42
|
+
});
|
|
43
|
+
|
|
32
44
|
// Intercepts anchor (#hash) clicks only. Routes and external links are left
|
|
33
45
|
// to NuxtLink/router unchanged. When reduced motion is preferred, the default
|
|
34
46
|
// browser/router anchor jump is preserved; otherwise we prevent that default
|
|
@@ -38,9 +50,10 @@ export const useAnchorScroll = (options: UseAnchorScrollOptions = {}) => {
|
|
|
38
50
|
if (prefersReducedMotion()) return;
|
|
39
51
|
|
|
40
52
|
event.preventDefault();
|
|
53
|
+
activeHash.value = href;
|
|
41
54
|
history.pushState(null, "", href);
|
|
42
55
|
scrollToAnchor(href);
|
|
43
56
|
};
|
|
44
57
|
|
|
45
|
-
return { handleNavClick, scrollToAnchor };
|
|
58
|
+
return { handleNavClick, scrollToAnchor, activeHash };
|
|
46
59
|
};
|
|
@@ -38,7 +38,11 @@ export const useNavCollapse = (stateKey: string, options: NavCollapseOptions = {
|
|
|
38
38
|
const route = useRoute();
|
|
39
39
|
|
|
40
40
|
const activeHref = computed(() => route.path);
|
|
41
|
-
const isActiveItem = (href?: string) =>
|
|
41
|
+
const isActiveItem = (href?: string) => {
|
|
42
|
+
if (!href) return false;
|
|
43
|
+
if (href.startsWith("#")) return href === route.hash;
|
|
44
|
+
return href === route.path;
|
|
45
|
+
};
|
|
42
46
|
|
|
43
47
|
watch(
|
|
44
48
|
() => route.path,
|
package/app/layouts/default.vue
CHANGED
|
@@ -92,6 +92,7 @@ const responsiveNavLinks = {
|
|
|
92
92
|
{ name: "Display Pill", path: "/ui/display-pill" },
|
|
93
93
|
{ name: "Qr Codes", path: "/ui/qr-code/display" },
|
|
94
94
|
{ name: "Anchor Scroll", path: "/ui/anchor-scroll" },
|
|
95
|
+
{ name: "Anchor Scroll (TabNavigation)", path: "/ui/anchor-scroll-tab-navigation" },
|
|
95
96
|
],
|
|
96
97
|
},
|
|
97
98
|
{
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<NuxtLayout name="default">
|
|
4
|
+
<template #layout-content>
|
|
5
|
+
<LayoutRow tag="div" variant="content" :style-class-passthrough="['mbe-4']">
|
|
6
|
+
<h1 class="page-heading-1">Anchor Scroll — TabNavigation</h1>
|
|
7
|
+
<p class="page-body-medium">
|
|
8
|
+
Tests <code class="inline-code">TabNavigation</code> with anchor hrefs. The nav uses
|
|
9
|
+
plain <code class="inline-code"><a></code> tags for hash links (not
|
|
10
|
+
<code class="inline-code">NuxtLink</code>) to keep Vue Router out of the
|
|
11
|
+
smooth-scroll path. The <code class="inline-code">:anchor-scroll-offset</code> prop
|
|
12
|
+
passes a getter so the sticky bar height is read at scroll time.
|
|
13
|
+
</p>
|
|
14
|
+
</LayoutRow>
|
|
15
|
+
|
|
16
|
+
<div ref="stickyNavRef" class="anchor-tab-nav-sticky">
|
|
17
|
+
<LayoutRow tag="div" variant="content">
|
|
18
|
+
<TabNavigation
|
|
19
|
+
:nav-item-data="anchorNavData"
|
|
20
|
+
:anchor-scroll-offset="() => stickyNavRef?.offsetHeight ?? 0"
|
|
21
|
+
/>
|
|
22
|
+
</LayoutRow>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<section id="overview" class="anchor-tab-section">
|
|
26
|
+
<LayoutRow tag="div" variant="content">
|
|
27
|
+
<h2 class="page-heading-2">Overview</h2>
|
|
28
|
+
<p class="page-body-medium">
|
|
29
|
+
This section verifies that clicking the <strong>Overview</strong> link above scrolls
|
|
30
|
+
here smoothly, with the heading landing below the sticky nav bar rather than behind it.
|
|
31
|
+
</p>
|
|
32
|
+
<p class="page-body-medium">
|
|
33
|
+
The sticky nav's height is resolved at click time via the getter passed to
|
|
34
|
+
<code class="inline-code">:anchor-scroll-offset</code>. Resize the viewport — the
|
|
35
|
+
offset adjusts automatically.
|
|
36
|
+
</p>
|
|
37
|
+
<p class="page-body-medium">
|
|
38
|
+
Hash links in <code class="inline-code">TabNavigation</code> render as plain
|
|
39
|
+
<code class="inline-code"><a></code> elements (not
|
|
40
|
+
<code class="inline-code">NuxtLink</code>), which prevents Vue Router from
|
|
41
|
+
intercepting the click and triggering its own scroll behaviour.
|
|
42
|
+
</p>
|
|
43
|
+
</LayoutRow>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section id="api" class="anchor-tab-section">
|
|
47
|
+
<LayoutRow tag="div" variant="content">
|
|
48
|
+
<h2 class="page-heading-2">API</h2>
|
|
49
|
+
<p class="page-body-medium">
|
|
50
|
+
This section verifies the <strong>API</strong> link. Confirm the heading lands
|
|
51
|
+
below the sticky nav after scrolling.
|
|
52
|
+
</p>
|
|
53
|
+
<p class="page-body-medium">
|
|
54
|
+
The <code class="inline-code">anchorScrollOffset</code> prop on
|
|
55
|
+
<code class="inline-code">TabNavigation</code> accepts
|
|
56
|
+
<code class="inline-code">number | (() => number)</code> — the same type as the
|
|
57
|
+
<code class="inline-code">offset</code> option on <code class="inline-code">useAnchorScroll</code>
|
|
58
|
+
directly. It is forwarded verbatim to the composable.
|
|
59
|
+
</p>
|
|
60
|
+
<div class="demo-code">const navItemData = {
|
|
61
|
+
main: [
|
|
62
|
+
{ text: "Overview", href: "#overview" },
|
|
63
|
+
{ text: "API", href: "#api" },
|
|
64
|
+
{ text: "Usage", href: "#usage" },
|
|
65
|
+
{ text: "Motion", href: "#motion" },
|
|
66
|
+
],
|
|
67
|
+
};</div>
|
|
68
|
+
<div class="demo-code"><div ref="stickyNavRef">
|
|
69
|
+
<TabNavigation
|
|
70
|
+
:nav-item-data="navItemData"
|
|
71
|
+
:anchor-scroll-offset="() => stickyNavRef?.offsetHeight ?? 0"
|
|
72
|
+
/>
|
|
73
|
+
</div></div>
|
|
74
|
+
</LayoutRow>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<section id="usage" class="anchor-tab-section">
|
|
78
|
+
<LayoutRow tag="div" variant="content">
|
|
79
|
+
<h2 class="page-heading-2">Usage</h2>
|
|
80
|
+
<p class="page-body-medium">
|
|
81
|
+
This section verifies the <strong>Usage</strong> link. Confirm the heading lands
|
|
82
|
+
below the sticky nav after scrolling.
|
|
83
|
+
</p>
|
|
84
|
+
<p class="page-body-medium">
|
|
85
|
+
Mixed navigation — routes and anchor hrefs in the same
|
|
86
|
+
<code class="inline-code">navItemData</code> — is supported. Route items render
|
|
87
|
+
as <code class="inline-code">NuxtLink</code>; anchor items render as plain
|
|
88
|
+
<code class="inline-code"><a></code>. The handler is a no-op for routes
|
|
89
|
+
so both types can share the same click binding.
|
|
90
|
+
</p>
|
|
91
|
+
<div class="demo-code">const navItemData = {
|
|
92
|
+
main: [
|
|
93
|
+
{ text: "About", href: "#about" }, // anchor — plain <a>
|
|
94
|
+
{ text: "Services", href: "#services" }, // anchor — plain <a>
|
|
95
|
+
{ text: "Blog", href: "/blog" }, // route — NuxtLink
|
|
96
|
+
{ text: "Contact", href: "#contact" }, // anchor — plain <a>
|
|
97
|
+
],
|
|
98
|
+
};</div>
|
|
99
|
+
</LayoutRow>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section id="motion" class="anchor-tab-section">
|
|
103
|
+
<LayoutRow tag="div" variant="content">
|
|
104
|
+
<h2 class="page-heading-2">Reduced Motion</h2>
|
|
105
|
+
<p class="page-body-medium">
|
|
106
|
+
This section verifies the <strong>Reduced Motion</strong> link. With
|
|
107
|
+
<em>Reduce Motion</em> enabled in OS accessibility settings, clicks should
|
|
108
|
+
produce an instant jump rather than a smooth scroll — and the offset should still
|
|
109
|
+
be respected.
|
|
110
|
+
</p>
|
|
111
|
+
<p class="page-body-medium">
|
|
112
|
+
To test on macOS: <strong>System Settings → Accessibility → Display → Reduce
|
|
113
|
+
Motion</strong>. Refresh the page after toggling, then click any section link
|
|
114
|
+
above.
|
|
115
|
+
</p>
|
|
116
|
+
</LayoutRow>
|
|
117
|
+
</section>
|
|
118
|
+
</template>
|
|
119
|
+
</NuxtLayout>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|
|
122
|
+
|
|
123
|
+
<script setup lang="ts">
|
|
124
|
+
import type { NavItemData } from "~/types/components";
|
|
125
|
+
|
|
126
|
+
definePageMeta({ layout: false });
|
|
127
|
+
|
|
128
|
+
const stickyNavRef = ref<HTMLElement | null>(null);
|
|
129
|
+
|
|
130
|
+
const anchorNavData: NavItemData = {
|
|
131
|
+
main: [
|
|
132
|
+
{ text: "Overview", href: "#overview" },
|
|
133
|
+
{ text: "API", href: "#api" },
|
|
134
|
+
{ text: "Usage", href: "#usage" },
|
|
135
|
+
{ text: "Reduced Motion", href: "#motion" },
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<style lang="css">
|
|
141
|
+
.anchor-tab-nav-sticky {
|
|
142
|
+
position: sticky;
|
|
143
|
+
top: 0;
|
|
144
|
+
z-index: 5;
|
|
145
|
+
background-color: var(--page-bg, #000);
|
|
146
|
+
border-block-end: 1px solid oklch(100% 0 0 / 10%);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.anchor-tab-section {
|
|
150
|
+
min-block-size: 60vh;
|
|
151
|
+
padding-block: 6rem 8rem;
|
|
152
|
+
border-block-end: 1px solid oklch(100% 0 0 / 8%);
|
|
153
|
+
|
|
154
|
+
&:last-child {
|
|
155
|
+
border-block-end: none;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.page-heading-2 {
|
|
159
|
+
margin-block-end: 2rem;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.page-body-medium {
|
|
163
|
+
margin-block-end: 1.6rem;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.demo-code {
|
|
168
|
+
background-color: oklch(100% 0 0 / 4%);
|
|
169
|
+
border: 1px solid oklch(100% 0 0 / 10%);
|
|
170
|
+
border-radius: 0.6rem;
|
|
171
|
+
padding: 1.8rem 2rem;
|
|
172
|
+
overflow-x: auto;
|
|
173
|
+
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
174
|
+
font-size: 1.3rem;
|
|
175
|
+
line-height: 1.65;
|
|
176
|
+
margin-block: 1.2rem 2rem;
|
|
177
|
+
white-space: pre;
|
|
178
|
+
color: var(--slate-01, currentColor);
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
<a
|
|
27
27
|
:href="`#${section.id}`"
|
|
28
28
|
class="anchor-nav-link"
|
|
29
|
+
:class="{ 'is-active': `#${section.id}` === activeHash }"
|
|
29
30
|
@click="(e) => handleNavClick(e, `#${section.id}`)"
|
|
30
31
|
>{{ section.label }}</a>
|
|
31
32
|
</li>
|
|
@@ -216,9 +217,13 @@ const sections = [
|
|
|
216
217
|
{ id: "motion", label: "Reduced Motion" },
|
|
217
218
|
];
|
|
218
219
|
|
|
219
|
-
const { handleNavClick } = useAnchorScroll({
|
|
220
|
+
const { handleNavClick, activeHash } = useAnchorScroll({
|
|
220
221
|
offset: () => stickyNavRef.value?.offsetHeight ?? 0,
|
|
221
222
|
});
|
|
223
|
+
|
|
224
|
+
onMounted(() => {
|
|
225
|
+
if (!activeHash.value && sections[0]) activeHash.value = `#${sections[0].id}`;
|
|
226
|
+
});
|
|
222
227
|
</script>
|
|
223
228
|
|
|
224
229
|
<style lang="css">
|
|
@@ -255,6 +260,11 @@ const { handleNavClick } = useAnchorScroll({
|
|
|
255
260
|
border-block-end-color: currentColor;
|
|
256
261
|
outline: none;
|
|
257
262
|
}
|
|
263
|
+
|
|
264
|
+
&.is-active {
|
|
265
|
+
color: var(--slate-00, currentColor);
|
|
266
|
+
border-block-end-color: currentColor;
|
|
267
|
+
}
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
.anchor-demo-section {
|