srcdev-nuxt-components 9.1.47 → 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 +293 -0
- package/.claude/skills/index.md +1 -0
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +36 -2
- package/app/composables/tests/useAnchorScroll.spec.ts +216 -0
- package/app/composables/useAnchorScroll.ts +59 -0
- package/app/composables/useNavCollapse.ts +5 -1
- package/app/layouts/default.vue +2 -0
- package/app/pages/ui/anchor-scroll-tab-navigation.vue +180 -0
- package/app/pages/ui/anchor-scroll.vue +343 -0
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# useAnchorScroll Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useAnchorScroll` intercepts `#hash` link clicks and smooth-scrolls to the target element.
|
|
6
|
+
Routes and external links pass through untouched, so the same handler is safe to attach to
|
|
7
|
+
every navigation link without conditional logic in the template.
|
|
8
|
+
|
|
9
|
+
Respects `prefers-reduced-motion` — when the user opts out of motion, `handleNavClick` returns
|
|
10
|
+
early without calling `preventDefault`, leaving the browser's native anchor jump intact.
|
|
11
|
+
|
|
12
|
+
**Ships inside the `srcdev-nuxt-components` layer** (`app/composables/useAnchorScroll.ts`).
|
|
13
|
+
Auto-imported by Nuxt — **do not create a local copy**.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## API reference
|
|
18
|
+
|
|
19
|
+
### Options
|
|
20
|
+
|
|
21
|
+
| Option | Type | Default | Description |
|
|
22
|
+
|---|---|---|---|
|
|
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. |
|
|
25
|
+
|
|
26
|
+
### Returns
|
|
27
|
+
|
|
28
|
+
| Name | Type | Description |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `handleNavClick` | `(event: MouseEvent, href: string) => void` | Attach to click handlers. No-ops silently for non-`#` hrefs — safe on all links. |
|
|
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).
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Usage patterns
|
|
76
|
+
|
|
77
|
+
### 1. TabNavigation with a sticky site header
|
|
78
|
+
|
|
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
|
+
```
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const headerRef = ref<HTMLElement | null>(null);
|
|
94
|
+
|
|
95
|
+
const navItemData = {
|
|
96
|
+
main: [
|
|
97
|
+
{ text: "About", href: "#about" },
|
|
98
|
+
{ text: "Services", href: "#services" },
|
|
99
|
+
{ text: "Contact", href: "#contact" },
|
|
100
|
+
{ text: "Blog", href: "/blog" }, // route — NuxtLink handles as normal
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
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
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
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.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### 2. Sticky section nav with dynamic offset (using offsetElement)
|
|
128
|
+
|
|
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.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
const stickyNavRef = ref<HTMLElement | null>(null);
|
|
134
|
+
|
|
135
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offsetElement: stickyNavRef });
|
|
136
|
+
|
|
137
|
+
onMounted(() => {
|
|
138
|
+
if (!activeHash.value && sections[0]) activeHash.value = `#${sections[0].id}`;
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```vue
|
|
143
|
+
<nav ref="stickyNavRef" class="sticky-section-nav">
|
|
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>
|
|
151
|
+
</nav>
|
|
152
|
+
|
|
153
|
+
<section v-for="section in sections" :key="section.id" :id="section.id">…</section>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
CSS to make the nav sticky:
|
|
157
|
+
|
|
158
|
+
```css
|
|
159
|
+
.sticky-section-nav {
|
|
160
|
+
position: sticky;
|
|
161
|
+
top: 0;
|
|
162
|
+
z-index: 5;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### 3. Terms / long-form page with sidebar nav
|
|
169
|
+
|
|
170
|
+
Same pattern as above — useful for terms, privacy policy, or documentation pages where a
|
|
171
|
+
sidebar links to in-document sections.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const { handleNavClick, activeHash } = useAnchorScroll({ offset: 24 }); // fixed header height
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```vue
|
|
178
|
+
<aside class="terms-sidebar">
|
|
179
|
+
<nav>
|
|
180
|
+
<a v-for="section in termsSections" :key="section.id"
|
|
181
|
+
:href="`#${section.id}`"
|
|
182
|
+
:class="{ 'is-active': `#${section.id}` === activeHash }"
|
|
183
|
+
@click="(e) => handleNavClick(e, `#${section.id}`)">
|
|
184
|
+
{{ section.title }}
|
|
185
|
+
</a>
|
|
186
|
+
</nav>
|
|
187
|
+
</aside>
|
|
188
|
+
|
|
189
|
+
<article>
|
|
190
|
+
<section v-for="section in termsSections" :key="section.id" :id="section.id">
|
|
191
|
+
<h2>{{ section.title }}</h2>
|
|
192
|
+
<p>{{ section.body }}</p>
|
|
193
|
+
</section>
|
|
194
|
+
</article>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### 4. Programmatic scroll (no click event)
|
|
200
|
+
|
|
201
|
+
Use `scrollToAnchor` to scroll without a click — after a form submission, on route entry, or
|
|
202
|
+
from any imperative call.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
const { scrollToAnchor } = useAnchorScroll({ offset: 64 });
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// Scroll to a section after successful form submit
|
|
210
|
+
const handleSubmit = async () => {
|
|
211
|
+
await submitForm();
|
|
212
|
+
scrollToAnchor("#confirmation");
|
|
213
|
+
};
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// Scroll to the current URL hash on mount
|
|
218
|
+
const route = useRoute();
|
|
219
|
+
|
|
220
|
+
onMounted(() => {
|
|
221
|
+
if (route.hash) scrollToAnchor(route.hash);
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## How offset works
|
|
228
|
+
|
|
229
|
+
Without `offset` or `offsetElement`, the composable calls `el.scrollIntoView({ behavior, block: "start" })` —
|
|
230
|
+
the element's top edge aligns with the viewport top.
|
|
231
|
+
|
|
232
|
+
With an offset, it uses `window.scrollTo` with a calculated position:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
top = el.getBoundingClientRect().top + window.scrollY - offset
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
This shifts the final resting position downward by `offset` pixels, so a sticky bar of that
|
|
239
|
+
height does not overlap the section heading.
|
|
240
|
+
|
|
241
|
+
**`offsetElement` vs `offset` getter:**
|
|
242
|
+
|
|
243
|
+
```ts
|
|
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({
|
|
249
|
+
offset: () => navRef.value?.offsetHeight ?? 0,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ✗ Static offset — stale if the bar changes height later
|
|
253
|
+
const { handleNavClick, activeHash } = useAnchorScroll({
|
|
254
|
+
offset: navRef.value?.offsetHeight ?? 0,
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Reduced motion behaviour
|
|
261
|
+
|
|
262
|
+
When `window.matchMedia("(prefers-reduced-motion: reduce)").matches` is `true`:
|
|
263
|
+
|
|
264
|
+
- **`handleNavClick`** — returns early without calling `preventDefault`. The browser or Vue
|
|
265
|
+
Router handles the anchor jump natively (instant, no scroll animation).
|
|
266
|
+
- **`scrollToAnchor`** — uses `behavior: "instant"` instead of `"smooth"`.
|
|
267
|
+
|
|
268
|
+
No configuration needed — the check happens at call time, so toggling the OS preference mid-session
|
|
269
|
+
takes effect immediately on the next click.
|
|
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
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Notes
|
|
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.
|
|
284
|
+
- **`history.pushState`** — `handleNavClick` pushes the hash into the URL so the back button and
|
|
285
|
+
deep links work correctly. This runs after `preventDefault` stops Vue Router from navigating,
|
|
286
|
+
so the URL stays in sync without triggering a router scroll.
|
|
287
|
+
- **Non-existent targets** — if no element matches the hash, `scrollToAnchor` silently returns.
|
|
288
|
+
No error is thrown, so attaching the handler to all links is safe even when some are routes or
|
|
289
|
+
the target section isn't on the current page.
|
|
290
|
+
- **SSR** — both `handleNavClick` and `scrollToAnchor` guard with `import.meta.server` and return
|
|
291
|
+
early. No special SSR setup is needed.
|
|
292
|
+
- **Multiple instances** — each call to `useAnchorScroll` is independent. You can run a sticky
|
|
293
|
+
section nav alongside a `TabNavigation` on the same page with different offsets.
|
package/.claude/skills/index.md
CHANGED
|
@@ -49,6 +49,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
49
49
|
├── composable-zod-validation.md — useZodValidation: schema-driven form validation, error binding, submit flow, API error push
|
|
50
50
|
├── composable-colour-scheme.md — useColourScheme: reactive light/dark/auto switching, localStorage persistence, runtime config
|
|
51
51
|
├── composable-dialog-controls.md — useDialogControls: named dialog open/close state with confirm/cancel callbacks
|
|
52
|
+
├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and TabNavigation integration
|
|
52
53
|
├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
|
|
53
54
|
└── components/
|
|
54
55
|
├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
|
|
@@ -19,14 +19,27 @@
|
|
|
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"
|
|
29
41
|
data-nav-item
|
|
42
|
+
@click="(e) => item.href && handleNavClick(e, item.href)"
|
|
30
43
|
>
|
|
31
44
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
32
45
|
{{ item.text }}
|
|
@@ -73,11 +86,21 @@
|
|
|
73
86
|
<div class="tab-nav-panel-inner">
|
|
74
87
|
<ul class="tab-nav-panel-list">
|
|
75
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>
|
|
76
98
|
<NuxtLink
|
|
99
|
+
v-else
|
|
77
100
|
:href="item.href"
|
|
78
101
|
:external="item.isExternal || undefined"
|
|
79
102
|
class="tab-nav-panel-link"
|
|
80
|
-
@click="closeMenu"
|
|
103
|
+
@click="(e) => { item.href && handleNavClick(e, item.href); closeMenu(); }"
|
|
81
104
|
>
|
|
82
105
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
83
106
|
{{ item.text }}
|
|
@@ -96,16 +119,27 @@ interface Props {
|
|
|
96
119
|
navItemData: NavItemData;
|
|
97
120
|
navAlign?: "left" | "center" | "right";
|
|
98
121
|
styleClassPassthrough?: string | string[];
|
|
122
|
+
anchorScrollOffset?: number | (() => number);
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
const props = withDefaults(defineProps<Props>(), {
|
|
102
126
|
navAlign: "left",
|
|
103
127
|
styleClassPassthrough: () => [],
|
|
128
|
+
anchorScrollOffset: undefined,
|
|
104
129
|
});
|
|
105
130
|
|
|
106
131
|
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu } =
|
|
107
132
|
useNavCollapse("tab-nav-loaded");
|
|
108
133
|
|
|
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
|
+
});
|
|
142
|
+
|
|
109
143
|
// ─── Animation gate — disables indicator transitions during route changes ────
|
|
110
144
|
// Starts true: CSS anchor positioning resolves before first paint so there is
|
|
111
145
|
// no previous position to animate from on initial render.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { useAnchorScroll } from "../useAnchorScroll";
|
|
3
|
+
|
|
4
|
+
const stubMatchMedia = (prefersReducedMotion: boolean) => {
|
|
5
|
+
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: prefersReducedMotion }));
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const makeEvent = (): MouseEvent => {
|
|
9
|
+
const e = new MouseEvent("click");
|
|
10
|
+
vi.spyOn(e, "preventDefault");
|
|
11
|
+
return e;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const appendEl = (id: string): HTMLElement => {
|
|
15
|
+
const el = document.createElement("div");
|
|
16
|
+
el.id = id;
|
|
17
|
+
document.body.appendChild(el);
|
|
18
|
+
return el;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("useAnchorScroll", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
document.body.innerHTML = "";
|
|
24
|
+
stubMatchMedia(false);
|
|
25
|
+
vi.stubGlobal("scrollTo", vi.fn());
|
|
26
|
+
vi.spyOn(history, "pushState").mockImplementation(() => {});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ─── handleNavClick ───────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("handleNavClick", () => {
|
|
36
|
+
describe("non-anchor hrefs", () => {
|
|
37
|
+
it("does nothing for a route href", () => {
|
|
38
|
+
const { handleNavClick } = useAnchorScroll();
|
|
39
|
+
const e = makeEvent();
|
|
40
|
+
handleNavClick(e, "/about");
|
|
41
|
+
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
42
|
+
expect(history.pushState).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does nothing for an external URL", () => {
|
|
46
|
+
const { handleNavClick } = useAnchorScroll();
|
|
47
|
+
const e = makeEvent();
|
|
48
|
+
handleNavClick(e, "https://example.com");
|
|
49
|
+
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does nothing for an empty string", () => {
|
|
53
|
+
const { handleNavClick } = useAnchorScroll();
|
|
54
|
+
const e = makeEvent();
|
|
55
|
+
handleNavClick(e, "");
|
|
56
|
+
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("prefers-reduced-motion active", () => {
|
|
61
|
+
it("does not call preventDefault", () => {
|
|
62
|
+
stubMatchMedia(true);
|
|
63
|
+
appendEl("overview");
|
|
64
|
+
const { handleNavClick } = useAnchorScroll();
|
|
65
|
+
const e = makeEvent();
|
|
66
|
+
handleNavClick(e, "#overview");
|
|
67
|
+
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does not push state", () => {
|
|
71
|
+
stubMatchMedia(true);
|
|
72
|
+
appendEl("overview");
|
|
73
|
+
const { handleNavClick } = useAnchorScroll();
|
|
74
|
+
handleNavClick(makeEvent(), "#overview");
|
|
75
|
+
expect(history.pushState).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not scroll", () => {
|
|
79
|
+
stubMatchMedia(true);
|
|
80
|
+
const el = appendEl("overview");
|
|
81
|
+
el.scrollIntoView = vi.fn();
|
|
82
|
+
const { handleNavClick } = useAnchorScroll();
|
|
83
|
+
handleNavClick(makeEvent(), "#overview");
|
|
84
|
+
expect(el.scrollIntoView).not.toHaveBeenCalled();
|
|
85
|
+
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("anchor href, no reduced-motion", () => {
|
|
90
|
+
it("calls preventDefault", () => {
|
|
91
|
+
appendEl("overview");
|
|
92
|
+
const { handleNavClick } = useAnchorScroll();
|
|
93
|
+
const e = makeEvent();
|
|
94
|
+
handleNavClick(e, "#overview");
|
|
95
|
+
expect(e.preventDefault).toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("pushes the hash into history", () => {
|
|
99
|
+
appendEl("overview");
|
|
100
|
+
const { handleNavClick } = useAnchorScroll();
|
|
101
|
+
handleNavClick(makeEvent(), "#overview");
|
|
102
|
+
expect(history.pushState).toHaveBeenCalledWith(null, "", "#overview");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does nothing when target element does not exist", () => {
|
|
106
|
+
const { handleNavClick } = useAnchorScroll();
|
|
107
|
+
expect(() => handleNavClick(makeEvent(), "#nonexistent")).not.toThrow();
|
|
108
|
+
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("scrolls to the element", () => {
|
|
112
|
+
const el = appendEl("overview");
|
|
113
|
+
el.scrollIntoView = vi.fn();
|
|
114
|
+
const { handleNavClick } = useAnchorScroll();
|
|
115
|
+
handleNavClick(makeEvent(), "#overview");
|
|
116
|
+
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── scrollToAnchor ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe("scrollToAnchor", () => {
|
|
124
|
+
describe("element lookup", () => {
|
|
125
|
+
it("accepts a hash with leading #", () => {
|
|
126
|
+
const el = appendEl("section");
|
|
127
|
+
el.scrollIntoView = vi.fn();
|
|
128
|
+
const { scrollToAnchor } = useAnchorScroll();
|
|
129
|
+
scrollToAnchor("#section");
|
|
130
|
+
expect(el.scrollIntoView).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("accepts a hash without leading #", () => {
|
|
134
|
+
const el = appendEl("section");
|
|
135
|
+
el.scrollIntoView = vi.fn();
|
|
136
|
+
const { scrollToAnchor } = useAnchorScroll();
|
|
137
|
+
scrollToAnchor("section");
|
|
138
|
+
expect(el.scrollIntoView).toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does nothing when no element matches", () => {
|
|
142
|
+
const { scrollToAnchor } = useAnchorScroll();
|
|
143
|
+
expect(() => scrollToAnchor("#ghost")).not.toThrow();
|
|
144
|
+
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("no offset", () => {
|
|
149
|
+
it("uses scrollIntoView with smooth behavior", () => {
|
|
150
|
+
const el = appendEl("target");
|
|
151
|
+
el.scrollIntoView = vi.fn();
|
|
152
|
+
const { scrollToAnchor } = useAnchorScroll();
|
|
153
|
+
scrollToAnchor("#target");
|
|
154
|
+
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("uses instant behavior when prefers-reduced-motion is active", () => {
|
|
158
|
+
stubMatchMedia(true);
|
|
159
|
+
const el = appendEl("target");
|
|
160
|
+
el.scrollIntoView = vi.fn();
|
|
161
|
+
const { scrollToAnchor } = useAnchorScroll();
|
|
162
|
+
scrollToAnchor("#target");
|
|
163
|
+
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: "instant", block: "start" });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("numeric offset", () => {
|
|
168
|
+
it("uses window.scrollTo with the offset subtracted", () => {
|
|
169
|
+
const el = appendEl("target");
|
|
170
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ top: 300 } as DOMRect);
|
|
171
|
+
const { scrollToAnchor } = useAnchorScroll({ offset: 80 });
|
|
172
|
+
scrollToAnchor("#target");
|
|
173
|
+
// top: el.top + scrollY - offset = 300 + 0 - 80
|
|
174
|
+
expect(window.scrollTo).toHaveBeenCalledWith({ top: 220, behavior: "smooth" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("uses instant behavior when prefers-reduced-motion is active", () => {
|
|
178
|
+
stubMatchMedia(true);
|
|
179
|
+
const el = appendEl("target");
|
|
180
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ top: 300 } as DOMRect);
|
|
181
|
+
const { scrollToAnchor } = useAnchorScroll({ offset: 80 });
|
|
182
|
+
scrollToAnchor("#target");
|
|
183
|
+
expect(window.scrollTo).toHaveBeenCalledWith({ top: 220, behavior: "instant" });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("getter offset", () => {
|
|
188
|
+
it("calls the getter at scroll time, not at composable init", () => {
|
|
189
|
+
const el = appendEl("target");
|
|
190
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ top: 0 } as DOMRect);
|
|
191
|
+
|
|
192
|
+
let liveOffset = 0;
|
|
193
|
+
const { scrollToAnchor } = useAnchorScroll({ offset: () => liveOffset });
|
|
194
|
+
|
|
195
|
+
liveOffset = 60;
|
|
196
|
+
scrollToAnchor("#target");
|
|
197
|
+
expect(window.scrollTo).toHaveBeenCalledWith({ top: -60, behavior: "smooth" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("re-reads the getter on each call", () => {
|
|
201
|
+
const el = appendEl("target");
|
|
202
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect);
|
|
203
|
+
|
|
204
|
+
let liveOffset = 40;
|
|
205
|
+
const { scrollToAnchor } = useAnchorScroll({ offset: () => liveOffset });
|
|
206
|
+
|
|
207
|
+
scrollToAnchor("#target");
|
|
208
|
+
expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 60, behavior: "smooth" });
|
|
209
|
+
|
|
210
|
+
liveOffset = 70;
|
|
211
|
+
scrollToAnchor("#target");
|
|
212
|
+
expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 30, behavior: "smooth" });
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
interface UseAnchorScrollOptions {
|
|
2
|
+
offset?: number | (() => number);
|
|
3
|
+
offsetElement?: Ref<HTMLElement | null>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const useAnchorScroll = (options: UseAnchorScrollOptions = {}) => {
|
|
7
|
+
const { offset = 0, offsetElement } = options;
|
|
8
|
+
|
|
9
|
+
const prefersReducedMotion = (): boolean => {
|
|
10
|
+
if (import.meta.server) return false;
|
|
11
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const resolveOffset = (): number => {
|
|
15
|
+
if (offsetElement) return offsetElement.value?.offsetHeight ?? 0;
|
|
16
|
+
return typeof offset === "function" ? offset() : offset;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const scrollToAnchor = (hash: string): void => {
|
|
20
|
+
if (import.meta.server) return;
|
|
21
|
+
const id = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
22
|
+
const el = document.getElementById(id);
|
|
23
|
+
if (!el) return;
|
|
24
|
+
|
|
25
|
+
const behavior: ScrollBehavior = prefersReducedMotion() ? "instant" : "smooth";
|
|
26
|
+
const px = resolveOffset();
|
|
27
|
+
|
|
28
|
+
if (px) {
|
|
29
|
+
const top = el.getBoundingClientRect().top + window.scrollY - px;
|
|
30
|
+
window.scrollTo({ top, behavior });
|
|
31
|
+
} else {
|
|
32
|
+
el.scrollIntoView({ behavior, block: "start" });
|
|
33
|
+
}
|
|
34
|
+
};
|
|
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
|
+
|
|
44
|
+
// Intercepts anchor (#hash) clicks only. Routes and external links are left
|
|
45
|
+
// to NuxtLink/router unchanged. When reduced motion is preferred, the default
|
|
46
|
+
// browser/router anchor jump is preserved; otherwise we prevent that default
|
|
47
|
+
// and smooth-scroll ourselves.
|
|
48
|
+
const handleNavClick = (event: MouseEvent, href: string): void => {
|
|
49
|
+
if (!href.startsWith("#")) return;
|
|
50
|
+
if (prefersReducedMotion()) return;
|
|
51
|
+
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
activeHash.value = href;
|
|
54
|
+
history.pushState(null, "", href);
|
|
55
|
+
scrollToAnchor(href);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return { handleNavClick, scrollToAnchor, activeHash };
|
|
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
|
@@ -91,6 +91,8 @@ const responsiveNavLinks = {
|
|
|
91
91
|
{ name: "Display Avatar", path: "/ui/display-avatar" },
|
|
92
92
|
{ name: "Display Pill", path: "/ui/display-pill" },
|
|
93
93
|
{ name: "Qr Codes", path: "/ui/qr-code/display" },
|
|
94
|
+
{ name: "Anchor Scroll", path: "/ui/anchor-scroll" },
|
|
95
|
+
{ name: "Anchor Scroll (TabNavigation)", path: "/ui/anchor-scroll-tab-navigation" },
|
|
94
96
|
],
|
|
95
97
|
},
|
|
96
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>
|
|
@@ -0,0 +1,343 @@
|
|
|
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">useAnchorScroll</h1>
|
|
7
|
+
<p class="page-body-medium">
|
|
8
|
+
Intercepts <code class="inline-code">#hash</code> link clicks and smooth-scrolls to
|
|
9
|
+
the target element. Routes and external links pass through to NuxtLink unchanged.
|
|
10
|
+
Respects <code class="inline-code">prefers-reduced-motion</code> — when the user opts
|
|
11
|
+
out of motion, the default browser anchor jump is preserved with no custom scroll code
|
|
12
|
+
involved.
|
|
13
|
+
</p>
|
|
14
|
+
<p class="page-body-medium">
|
|
15
|
+
This page demonstrates the composable directly: the sticky bar below uses
|
|
16
|
+
<code class="inline-code">useAnchorScroll</code> with a dynamic offset so section
|
|
17
|
+
headings always land just below the bar after scrolling.
|
|
18
|
+
</p>
|
|
19
|
+
</LayoutRow>
|
|
20
|
+
|
|
21
|
+
<div ref="stickyNavRef" class="anchor-scroll-sticky-nav">
|
|
22
|
+
<LayoutRow tag="div" variant="content">
|
|
23
|
+
<nav aria-label="Page sections">
|
|
24
|
+
<ul class="anchor-nav-list">
|
|
25
|
+
<li v-for="section in sections" :key="section.id">
|
|
26
|
+
<a
|
|
27
|
+
:href="`#${section.id}`"
|
|
28
|
+
class="anchor-nav-link"
|
|
29
|
+
:class="{ 'is-active': `#${section.id}` === activeHash }"
|
|
30
|
+
@click="(e) => handleNavClick(e, `#${section.id}`)"
|
|
31
|
+
>{{ section.label }}</a>
|
|
32
|
+
</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</nav>
|
|
35
|
+
</LayoutRow>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<section id="overview" class="anchor-demo-section">
|
|
39
|
+
<LayoutRow tag="div" variant="content">
|
|
40
|
+
<h2 class="page-heading-2">Overview</h2>
|
|
41
|
+
<p class="page-body-medium">
|
|
42
|
+
Single-page and anchor-linked layouts need a bridge between
|
|
43
|
+
<code class="inline-code">NuxtLink</code> (which handles routes) and the browser's
|
|
44
|
+
native anchor scrolling (which has no smooth-scroll guarantee).
|
|
45
|
+
<code class="inline-code">useAnchorScroll</code> fills that gap.
|
|
46
|
+
</p>
|
|
47
|
+
<p class="page-body-medium">
|
|
48
|
+
Pass any <code class="inline-code">#hash</code> href to
|
|
49
|
+
<code class="inline-code">handleNavClick</code> — it prevents the default jump,
|
|
50
|
+
pushes the hash into the URL, and scrolls to the matching element. Every other href
|
|
51
|
+
(routes, external URLs) is a no-op, so the same handler is safe to attach to all
|
|
52
|
+
navigation links without conditional logic in the template.
|
|
53
|
+
</p>
|
|
54
|
+
<p class="page-body-medium">
|
|
55
|
+
An optional <code class="inline-code">offset</code> shifts the final scroll position
|
|
56
|
+
upward — useful when a sticky bar would otherwise obscure the section heading. Pass a
|
|
57
|
+
number for a fixed bar height or a getter function to read the bar's live height at
|
|
58
|
+
scroll time.
|
|
59
|
+
</p>
|
|
60
|
+
</LayoutRow>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<section id="api" class="anchor-demo-section">
|
|
64
|
+
<LayoutRow tag="div" variant="content">
|
|
65
|
+
<h2 class="page-heading-2">API</h2>
|
|
66
|
+
|
|
67
|
+
<h3 class="page-heading-3">Options</h3>
|
|
68
|
+
<div class="api-table-wrapper">
|
|
69
|
+
<table class="api-table">
|
|
70
|
+
<thead>
|
|
71
|
+
<tr>
|
|
72
|
+
<th>Option</th>
|
|
73
|
+
<th>Type</th>
|
|
74
|
+
<th>Default</th>
|
|
75
|
+
<th>Description</th>
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
<tr>
|
|
80
|
+
<td><code class="inline-code">offset</code></td>
|
|
81
|
+
<td><code class="inline-code">number | (() => number)</code></td>
|
|
82
|
+
<td><code class="inline-code">0</code></td>
|
|
83
|
+
<td>
|
|
84
|
+
Pixels subtracted from the scroll-to position. Pass a getter to read a sticky
|
|
85
|
+
element's height at scroll time rather than at composable init.
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
</tbody>
|
|
89
|
+
</table>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<h3 class="page-heading-3">Returns</h3>
|
|
93
|
+
<div class="api-table-wrapper">
|
|
94
|
+
<table class="api-table">
|
|
95
|
+
<thead>
|
|
96
|
+
<tr>
|
|
97
|
+
<th>Name</th>
|
|
98
|
+
<th>Type</th>
|
|
99
|
+
<th>Description</th>
|
|
100
|
+
</tr>
|
|
101
|
+
</thead>
|
|
102
|
+
<tbody>
|
|
103
|
+
<tr>
|
|
104
|
+
<td><code class="inline-code">handleNavClick</code></td>
|
|
105
|
+
<td><code class="inline-code">(event: MouseEvent, href: string) => void</code></td>
|
|
106
|
+
<td>
|
|
107
|
+
Attach to click handlers. No-ops silently for non-anchor hrefs so it is safe
|
|
108
|
+
on all links.
|
|
109
|
+
</td>
|
|
110
|
+
</tr>
|
|
111
|
+
<tr>
|
|
112
|
+
<td><code class="inline-code">scrollToAnchor</code></td>
|
|
113
|
+
<td><code class="inline-code">(hash: string) => void</code></td>
|
|
114
|
+
<td>
|
|
115
|
+
Programmatically scroll to a hash string (with or without the leading
|
|
116
|
+
<code class="inline-code">#</code>). Respects the same motion preference and
|
|
117
|
+
offset.
|
|
118
|
+
</td>
|
|
119
|
+
</tr>
|
|
120
|
+
</tbody>
|
|
121
|
+
</table>
|
|
122
|
+
</div>
|
|
123
|
+
</LayoutRow>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<section id="usage" class="anchor-demo-section">
|
|
127
|
+
<LayoutRow tag="div" variant="content">
|
|
128
|
+
<h2 class="page-heading-2">Usage</h2>
|
|
129
|
+
|
|
130
|
+
<h3 class="page-heading-3">In TabNavigation</h3>
|
|
131
|
+
<p class="page-body-medium">
|
|
132
|
+
<code class="inline-code">TabNavigation</code> already has
|
|
133
|
+
<code class="inline-code">useAnchorScroll</code> wired up internally. Pass anchor
|
|
134
|
+
hrefs in <code class="inline-code">navItemData</code> and it works automatically.
|
|
135
|
+
</p>
|
|
136
|
+
<pre class="demo-code">const navItemData = {
|
|
137
|
+
main: [
|
|
138
|
+
{ text: "About", href: "#about" },
|
|
139
|
+
{ text: "Services", href: "#services" },
|
|
140
|
+
{ text: "Contact", href: "#contact" },
|
|
141
|
+
{ text: "Blog", href: "/blog" }, // route — handled by NuxtLink as normal
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
</pre>
|
|
145
|
+
<pre class="demo-code"><TabNavigation :nav-item-data="navItemData" />
|
|
146
|
+
</pre>
|
|
147
|
+
|
|
148
|
+
<h3 class="page-heading-3">Standalone (this page)</h3>
|
|
149
|
+
<p class="page-body-medium">
|
|
150
|
+
For custom anchor navs — like a sticky section bar or a terms-page sidebar — call
|
|
151
|
+
the composable directly. Pass a getter for
|
|
152
|
+
<code class="inline-code">offset</code> so it reads the element height at scroll
|
|
153
|
+
time rather than on mount.
|
|
154
|
+
</p>
|
|
155
|
+
<pre class="demo-code">const stickyNavRef = ref<HTMLElement | null>(null);
|
|
156
|
+
|
|
157
|
+
const { handleNavClick } = useAnchorScroll({
|
|
158
|
+
offset: () => stickyNavRef.value?.offsetHeight ?? 0,
|
|
159
|
+
});
|
|
160
|
+
</pre>
|
|
161
|
+
<pre class="demo-code"><div ref="stickyNavRef" class="sticky-nav">
|
|
162
|
+
<a href="#overview" @click="(e) => handleNavClick(e, '#overview')">Overview</a>
|
|
163
|
+
<a href="#api" @click="(e) => handleNavClick(e, '#api')">API</a>
|
|
164
|
+
</div>
|
|
165
|
+
</pre>
|
|
166
|
+
|
|
167
|
+
<h3 class="page-heading-3">Programmatic scroll</h3>
|
|
168
|
+
<p class="page-body-medium">
|
|
169
|
+
Use <code class="inline-code">scrollToAnchor</code> when you need to scroll without
|
|
170
|
+
a click event — for example, after a form submission or on route entry.
|
|
171
|
+
</p>
|
|
172
|
+
<pre class="demo-code">const { scrollToAnchor } = useAnchorScroll({ offset: 64 });
|
|
173
|
+
|
|
174
|
+
onMounted(() => {
|
|
175
|
+
if (route.hash) scrollToAnchor(route.hash);
|
|
176
|
+
});
|
|
177
|
+
</pre>
|
|
178
|
+
</LayoutRow>
|
|
179
|
+
</section>
|
|
180
|
+
|
|
181
|
+
<section id="motion" class="anchor-demo-section">
|
|
182
|
+
<LayoutRow tag="div" variant="content">
|
|
183
|
+
<h2 class="page-heading-2">Reduced Motion</h2>
|
|
184
|
+
<p class="page-body-medium">
|
|
185
|
+
When <code class="inline-code">prefers-reduced-motion: reduce</code> is active,
|
|
186
|
+
<code class="inline-code">handleNavClick</code> returns early without calling
|
|
187
|
+
<code class="inline-code">preventDefault</code>. The browser or Vue Router handles
|
|
188
|
+
the anchor navigation natively — an instant jump with no custom scroll code
|
|
189
|
+
involved. No special configuration needed.
|
|
190
|
+
</p>
|
|
191
|
+
<p class="page-body-medium">
|
|
192
|
+
To test: open your OS accessibility settings, enable Reduce Motion, then click
|
|
193
|
+
a section link in the bar above. The page will jump immediately instead of
|
|
194
|
+
scrolling.
|
|
195
|
+
</p>
|
|
196
|
+
<p class="page-body-medium">
|
|
197
|
+
<code class="inline-code">scrollToAnchor</code> respects the same preference:
|
|
198
|
+
it uses <code class="inline-code">behavior: "instant"</code> when reduced motion
|
|
199
|
+
is detected, so programmatic scrolls are equally accessible.
|
|
200
|
+
</p>
|
|
201
|
+
</LayoutRow>
|
|
202
|
+
</section>
|
|
203
|
+
</template>
|
|
204
|
+
</NuxtLayout>
|
|
205
|
+
</div>
|
|
206
|
+
</template>
|
|
207
|
+
|
|
208
|
+
<script setup lang="ts">
|
|
209
|
+
definePageMeta({ layout: false });
|
|
210
|
+
|
|
211
|
+
const stickyNavRef = ref<HTMLElement | null>(null);
|
|
212
|
+
|
|
213
|
+
const sections = [
|
|
214
|
+
{ id: "overview", label: "Overview" },
|
|
215
|
+
{ id: "api", label: "API" },
|
|
216
|
+
{ id: "usage", label: "Usage" },
|
|
217
|
+
{ id: "motion", label: "Reduced Motion" },
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
const { handleNavClick, activeHash } = useAnchorScroll({
|
|
221
|
+
offset: () => stickyNavRef.value?.offsetHeight ?? 0,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
onMounted(() => {
|
|
225
|
+
if (!activeHash.value && sections[0]) activeHash.value = `#${sections[0].id}`;
|
|
226
|
+
});
|
|
227
|
+
</script>
|
|
228
|
+
|
|
229
|
+
<style lang="css">
|
|
230
|
+
.anchor-scroll-sticky-nav {
|
|
231
|
+
position: sticky;
|
|
232
|
+
top: 0;
|
|
233
|
+
z-index: 5;
|
|
234
|
+
background-color: var(--page-bg, #000);
|
|
235
|
+
border-block-end: 1px solid oklch(100% 0 0 / 10%);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.anchor-nav-list {
|
|
239
|
+
list-style: none;
|
|
240
|
+
margin: 0;
|
|
241
|
+
padding: 0;
|
|
242
|
+
display: flex;
|
|
243
|
+
flex-wrap: wrap;
|
|
244
|
+
gap: 0.4rem 2rem;
|
|
245
|
+
padding-block: 1.2rem;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.anchor-nav-link {
|
|
249
|
+
color: var(--slate-02, currentColor);
|
|
250
|
+
font-size: 1.4rem;
|
|
251
|
+
letter-spacing: 0.04em;
|
|
252
|
+
text-decoration: none;
|
|
253
|
+
padding-block: 0.4rem;
|
|
254
|
+
border-block-end: 1.5px solid transparent;
|
|
255
|
+
transition: color 200ms ease, border-color 200ms ease;
|
|
256
|
+
|
|
257
|
+
&:hover,
|
|
258
|
+
&:focus-visible {
|
|
259
|
+
color: var(--slate-00, currentColor);
|
|
260
|
+
border-block-end-color: currentColor;
|
|
261
|
+
outline: none;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
&.is-active {
|
|
265
|
+
color: var(--slate-00, currentColor);
|
|
266
|
+
border-block-end-color: currentColor;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.anchor-demo-section {
|
|
271
|
+
min-block-size: 60vh;
|
|
272
|
+
padding-block: 6rem 8rem;
|
|
273
|
+
border-block-end: 1px solid oklch(100% 0 0 / 8%);
|
|
274
|
+
|
|
275
|
+
&:last-child {
|
|
276
|
+
border-block-end: none;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.page-heading-2 {
|
|
280
|
+
margin-block-end: 2rem;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.page-heading-3 {
|
|
284
|
+
margin-block: 3.2rem 1.2rem;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.page-body-medium {
|
|
288
|
+
margin-block-end: 1.6rem;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.api-table-wrapper {
|
|
293
|
+
overflow-x: auto;
|
|
294
|
+
margin-block: 1.6rem 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.api-table {
|
|
298
|
+
width: 100%;
|
|
299
|
+
border-collapse: collapse;
|
|
300
|
+
font-size: 1.4rem;
|
|
301
|
+
line-height: 1.5;
|
|
302
|
+
|
|
303
|
+
th,
|
|
304
|
+
td {
|
|
305
|
+
text-align: left;
|
|
306
|
+
padding: 1rem 1.6rem;
|
|
307
|
+
border: 1px solid oklch(100% 0 0 / 12%);
|
|
308
|
+
vertical-align: top;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
th {
|
|
312
|
+
background-color: oklch(100% 0 0 / 4%);
|
|
313
|
+
font-weight: 600;
|
|
314
|
+
color: var(--slate-01, currentColor);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
td {
|
|
318
|
+
color: var(--slate-02, currentColor);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.demo-code {
|
|
323
|
+
background-color: oklch(100% 0 0 / 4%);
|
|
324
|
+
border: 1px solid oklch(100% 0 0 / 10%);
|
|
325
|
+
border-radius: 0.6rem;
|
|
326
|
+
padding: 1.8rem 2rem;
|
|
327
|
+
overflow-x: auto;
|
|
328
|
+
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
329
|
+
font-size: 1.3rem;
|
|
330
|
+
line-height: 1.65;
|
|
331
|
+
margin-block: 1.2rem 2rem;
|
|
332
|
+
white-space: pre;
|
|
333
|
+
color: var(--slate-01, currentColor);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.inline-code {
|
|
337
|
+
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
338
|
+
font-size: 0.875em;
|
|
339
|
+
background-color: oklch(100% 0 0 / 8%);
|
|
340
|
+
padding: 0.15em 0.4em;
|
|
341
|
+
border-radius: 0.3rem;
|
|
342
|
+
}
|
|
343
|
+
</style>
|