srcdev-nuxt-components 9.1.46 → 9.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/components/scroll-reveal-image.md +2 -1
- package/.claude/skills/composable-anchor-scroll.md +211 -0
- package/.claude/skills/index.md +1 -0
- package/app/components/01.atoms/animations/scroll-reveal-image/ScrollRevealImage.vue +3 -1
- package/app/components/01.atoms/animations/scroll-reveal-image/tests/ScrollRevealImage.spec.ts +9 -1
- package/app/components/01.atoms/animations/scroll-reveal-image/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +1 -1
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +4 -1
- package/app/components/02.molecules/profile-section/ProfileSection.vue +0 -5
- package/app/composables/tests/useAnchorScroll.spec.ts +216 -0
- package/app/composables/useAnchorScroll.ts +46 -0
- package/app/layouts/default.vue +1 -0
- package/app/pages/ui/anchor-scroll.vue +333 -0
- package/package.json +1 -1
|
@@ -24,6 +24,7 @@ For arbitrary slot content — a grid of images, video, markup — use `ScrollRe
|
|
|
24
24
|
| `parallaxOffset` | `string` | `"36rem"` | Distance the image travels vertically across the full scroll range. Larger = more dramatic reveal. |
|
|
25
25
|
| `focalX` | `string` | `"50%"` | Horizontal focal point — CSS `object-position` x-axis value. Controls which horizontal slice stays in view. |
|
|
26
26
|
| `radius` | `string` | `"0px"` | `border-radius` applied to the clipping frame. |
|
|
27
|
+
| `loading` | `"lazy" \| "eager"` | `"lazy"` | Image loading strategy. Use `"eager"` if this is the LCP image (e.g. partially in view on load). |
|
|
27
28
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes applied to the root `<figure>`. |
|
|
28
29
|
|
|
29
30
|
## Basic usage
|
|
@@ -157,7 +158,7 @@ See `scroll-reveal-frame.md` for the full guide. For portrait images the default
|
|
|
157
158
|
## Notes
|
|
158
159
|
|
|
159
160
|
- The root `<figure>` has `margin: 0` set in the component — browser default `<figure>` margins are neutralised at source.
|
|
160
|
-
- `
|
|
161
|
+
- `decoding="async"` is always set on the `<img>`. Use `:loading="'eager'"` if this component is the LCP image (e.g. a hero partially in view on load) — the default `"lazy"` is correct for below-fold usage.
|
|
161
162
|
- Do not place inside a container with `overflow: hidden` or `overflow: clip` — breaks the `view-timeline` scroll detection inherited from `ScrollRevealFrame`.
|
|
162
163
|
- Reduced-motion: animation is disabled and the image falls back to a static crop centred at `object-position: <focalX> 50%`.
|
|
163
164
|
- Storybook: the `"none"` image provider is active (`nuxt.config.ts`), so `src` paths pass through unchanged. Always provide explicit `img-width` and `img-height` props to avoid the `w=1536` fallback in deployed Storybook.
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
|
|
25
|
+
### Returns
|
|
26
|
+
|
|
27
|
+
| Name | Type | Description |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `handleNavClick` | `(event: MouseEvent, href: string) => void` | Attach to click handlers. No-ops silently for non-`#` hrefs — safe on all links. |
|
|
30
|
+
| `scrollToAnchor` | `(hash: string) => void` | Scroll programmatically. Accepts `"#section"` or `"section"`. Respects motion preference and offset. |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Usage patterns
|
|
35
|
+
|
|
36
|
+
### 1. TabNavigation with anchor links (no extra code needed)
|
|
37
|
+
|
|
38
|
+
`TabNavigation` has `useAnchorScroll` wired up internally. Pass anchor hrefs in
|
|
39
|
+
`navItemData.main` and smooth scrolling works automatically.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const navItemData = {
|
|
43
|
+
main: [
|
|
44
|
+
{ text: "About", href: "#about" },
|
|
45
|
+
{ text: "Services", href: "#services" },
|
|
46
|
+
{ text: "Contact", href: "#contact" },
|
|
47
|
+
{ text: "Blog", href: "/blog" }, // route — NuxtLink handles as normal
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```vue
|
|
53
|
+
<TabNavigation :nav-item-data="navItemData" />
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
No offset is applied here. If your site header is fixed/sticky and obscures section headings,
|
|
57
|
+
use pattern 2 below to build a custom nav with an explicit offset.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### 2. Sticky section nav with dynamic offset
|
|
62
|
+
|
|
63
|
+
The most common single-page pattern: a sticky bar at the top of the content area, where
|
|
64
|
+
each link scrolls to a section below it. Pass a getter for `offset` so the bar's live height
|
|
65
|
+
is read at scroll time — this stays correct if the bar resizes (e.g. on viewport changes).
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const stickyNavRef = ref<HTMLElement | null>(null);
|
|
69
|
+
|
|
70
|
+
const { handleNavClick } = useAnchorScroll({
|
|
71
|
+
offset: () => stickyNavRef.value?.offsetHeight ?? 0,
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```vue
|
|
76
|
+
<nav ref="stickyNavRef" class="sticky-section-nav">
|
|
77
|
+
<a href="#overview" @click="(e) => handleNavClick(e, '#overview')">Overview</a>
|
|
78
|
+
<a href="#pricing" @click="(e) => handleNavClick(e, '#pricing')">Pricing</a>
|
|
79
|
+
<a href="#contact" @click="(e) => handleNavClick(e, '#contact')">Contact</a>
|
|
80
|
+
</nav>
|
|
81
|
+
|
|
82
|
+
<section id="overview">…</section>
|
|
83
|
+
<section id="pricing">…</section>
|
|
84
|
+
<section id="contact">…</section>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
CSS to make the nav sticky:
|
|
88
|
+
|
|
89
|
+
```css
|
|
90
|
+
.sticky-section-nav {
|
|
91
|
+
position: sticky;
|
|
92
|
+
top: 0;
|
|
93
|
+
z-index: 5;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### 3. Terms / long-form page with sidebar nav
|
|
100
|
+
|
|
101
|
+
Same pattern as above — useful for terms, privacy policy, or documentation pages where a
|
|
102
|
+
sidebar links to in-document sections.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const { handleNavClick } = useAnchorScroll({ offset: 24 }); // fixed header height
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```vue
|
|
109
|
+
<aside class="terms-sidebar">
|
|
110
|
+
<nav>
|
|
111
|
+
<a v-for="section in termsSections" :key="section.id"
|
|
112
|
+
:href="`#${section.id}`"
|
|
113
|
+
@click="(e) => handleNavClick(e, `#${section.id}`)">
|
|
114
|
+
{{ section.title }}
|
|
115
|
+
</a>
|
|
116
|
+
</nav>
|
|
117
|
+
</aside>
|
|
118
|
+
|
|
119
|
+
<article>
|
|
120
|
+
<section v-for="section in termsSections" :key="section.id" :id="section.id">
|
|
121
|
+
<h2>{{ section.title }}</h2>
|
|
122
|
+
<p>{{ section.body }}</p>
|
|
123
|
+
</section>
|
|
124
|
+
</article>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### 4. Programmatic scroll (no click event)
|
|
130
|
+
|
|
131
|
+
Use `scrollToAnchor` to scroll without a click — after a form submission, on route entry, or
|
|
132
|
+
from any imperative call.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const { scrollToAnchor } = useAnchorScroll({ offset: 64 });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// Scroll to a section after successful form submit
|
|
140
|
+
const handleSubmit = async () => {
|
|
141
|
+
await submitForm();
|
|
142
|
+
scrollToAnchor("#confirmation");
|
|
143
|
+
};
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// Scroll to the current URL hash on mount
|
|
148
|
+
const route = useRoute();
|
|
149
|
+
|
|
150
|
+
onMounted(() => {
|
|
151
|
+
if (route.hash) scrollToAnchor(route.hash);
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## How offset works
|
|
158
|
+
|
|
159
|
+
Without `offset`, the composable calls `el.scrollIntoView({ behavior, block: "start" })` —
|
|
160
|
+
the element's top edge aligns with the viewport top.
|
|
161
|
+
|
|
162
|
+
With `offset`, it uses `window.scrollTo` with a calculated position:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
top = el.getBoundingClientRect().top + window.scrollY - offset
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This shifts the final resting position downward by `offset` pixels, so a sticky bar of that
|
|
169
|
+
height does not overlap the section heading.
|
|
170
|
+
|
|
171
|
+
**Use a getter when the bar can resize:**
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// ✅ Read height at click time
|
|
175
|
+
const { handleNavClick } = useAnchorScroll({
|
|
176
|
+
offset: () => navRef.value?.offsetHeight ?? 0,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ✗ Captured at init — stale if the bar changes height later
|
|
180
|
+
const { handleNavClick } = useAnchorScroll({
|
|
181
|
+
offset: navRef.value?.offsetHeight ?? 0,
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Reduced motion behaviour
|
|
188
|
+
|
|
189
|
+
When `window.matchMedia("(prefers-reduced-motion: reduce)").matches` is `true`:
|
|
190
|
+
|
|
191
|
+
- **`handleNavClick`** — returns early without calling `preventDefault`. The browser or Vue
|
|
192
|
+
Router handles the anchor jump natively (instant, no scroll animation).
|
|
193
|
+
- **`scrollToAnchor`** — uses `behavior: "instant"` instead of `"smooth"`.
|
|
194
|
+
|
|
195
|
+
No configuration needed — the check happens at call time, so toggling the OS preference mid-session
|
|
196
|
+
takes effect immediately on the next click.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Notes
|
|
201
|
+
|
|
202
|
+
- **`history.pushState`** — `handleNavClick` pushes the hash into the URL so the back button and
|
|
203
|
+
deep links work correctly. This runs after `preventDefault` stops Vue Router from navigating,
|
|
204
|
+
so the URL stays in sync without triggering a router scroll.
|
|
205
|
+
- **Non-existent targets** — if no element matches the hash, `scrollToAnchor` silently returns.
|
|
206
|
+
No error is thrown, so attaching the handler to all links is safe even when some are routes or
|
|
207
|
+
the target section isn't on the current page.
|
|
208
|
+
- **SSR** — both `handleNavClick` and `scrollToAnchor` guard with `import.meta.server` and return
|
|
209
|
+
early. No special SSR setup is needed.
|
|
210
|
+
- **Multiple instances** — each call to `useAnchorScroll` is independent. You can run a sticky
|
|
211
|
+
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
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
:alt="alt"
|
|
13
13
|
:width="imgWidth"
|
|
14
14
|
:height="imgHeight"
|
|
15
|
-
loading="
|
|
15
|
+
:loading="loading"
|
|
16
16
|
decoding="async"
|
|
17
17
|
/>
|
|
18
18
|
</ScrollRevealFrame>
|
|
@@ -51,6 +51,7 @@ interface Props {
|
|
|
51
51
|
focalX?: string;
|
|
52
52
|
/** Optional rounded corners on the frame. */
|
|
53
53
|
radius?: string;
|
|
54
|
+
loading?: "lazy" | "eager";
|
|
54
55
|
styleClassPassthrough?: string | string[];
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -62,6 +63,7 @@ withDefaults(defineProps<Props>(), {
|
|
|
62
63
|
parallaxOffset: "36rem",
|
|
63
64
|
focalX: "50%",
|
|
64
65
|
radius: "0px",
|
|
66
|
+
loading: "lazy",
|
|
65
67
|
styleClassPassthrough: () => [],
|
|
66
68
|
});
|
|
67
69
|
</script>
|
package/app/components/01.atoms/animations/scroll-reveal-image/tests/ScrollRevealImage.spec.ts
CHANGED
|
@@ -32,6 +32,7 @@ describe("ScrollRevealImage", () => {
|
|
|
32
32
|
parallaxOffset: "20rem",
|
|
33
33
|
focalX: "30%",
|
|
34
34
|
radius: "1.6rem",
|
|
35
|
+
loading: "eager",
|
|
35
36
|
styleClassPassthrough: ["custom-class"],
|
|
36
37
|
},
|
|
37
38
|
});
|
|
@@ -179,13 +180,20 @@ describe("ScrollRevealImage", () => {
|
|
|
179
180
|
expect(img.attributes("height")).toBe("1920");
|
|
180
181
|
});
|
|
181
182
|
|
|
182
|
-
it("sets loading=lazy on the image element", async () => {
|
|
183
|
+
it("sets loading=lazy on the image element by default", async () => {
|
|
183
184
|
const wrapper = await mountSuspended(ScrollRevealImage, {
|
|
184
185
|
props: { src: "/images/test.jpg" },
|
|
185
186
|
});
|
|
186
187
|
expect(wrapper.find("img[data-nuxt-img]").attributes("loading")).toBe("lazy");
|
|
187
188
|
});
|
|
188
189
|
|
|
190
|
+
it("passes loading=eager to the image element when set", async () => {
|
|
191
|
+
const wrapper = await mountSuspended(ScrollRevealImage, {
|
|
192
|
+
props: { src: "/images/test.jpg", loading: "eager" },
|
|
193
|
+
});
|
|
194
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("loading")).toBe("eager");
|
|
195
|
+
});
|
|
196
|
+
|
|
189
197
|
it("sets decoding=async on the image element", async () => {
|
|
190
198
|
const wrapper = await mountSuspended(ScrollRevealImage, {
|
|
191
199
|
props: { src: "/images/test.jpg" },
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
exports[`ScrollRevealImage > renders correct HTML structure (all props set) 1`] = `
|
|
4
4
|
"<figure class="reveal-frame custom-class" style="--_frame-height: 400px; --_parallax-offset: 20rem; --_radius: 1.6rem; --_focal-x: 30%;">
|
|
5
|
-
<div class="reveal-content"><img width="800" height="600" data-nuxt-img="" srcset="/_ipx/s_800x600/images/test.jpg 1x, /_ipx/s_1600x1200/images/test.jpg 2x" class="reveal-image" alt="All props" loading="
|
|
5
|
+
<div class="reveal-content"><img width="800" height="600" data-nuxt-img="" srcset="/_ipx/s_800x600/images/test.jpg 1x, /_ipx/s_1600x1200/images/test.jpg 2x" class="reveal-image" alt="All props" loading="eager" decoding="async" src="/_ipx/s_800x600/images/test.jpg"></div>
|
|
6
6
|
</figure>"
|
|
7
7
|
`;
|
|
8
8
|
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
:external="item.isExternal || undefined"
|
|
28
28
|
class="tab-nav-link"
|
|
29
29
|
data-nav-item
|
|
30
|
+
@click="(e) => item.href && handleNavClick(e, item.href)"
|
|
30
31
|
>
|
|
31
32
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
32
33
|
{{ item.text }}
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
:href="item.href"
|
|
78
79
|
:external="item.isExternal || undefined"
|
|
79
80
|
class="tab-nav-panel-link"
|
|
80
|
-
@click="closeMenu"
|
|
81
|
+
@click="(e) => { item.href && handleNavClick(e, item.href); closeMenu(); }"
|
|
81
82
|
>
|
|
82
83
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
83
84
|
{{ item.text }}
|
|
@@ -106,6 +107,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
106
107
|
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu } =
|
|
107
108
|
useNavCollapse("tab-nav-loaded");
|
|
108
109
|
|
|
110
|
+
const { handleNavClick } = useAnchorScroll();
|
|
111
|
+
|
|
109
112
|
// ─── Animation gate — disables indicator transitions during route changes ────
|
|
110
113
|
// Starts true: CSS anchor positioning resolves before first paint so there is
|
|
111
114
|
// 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,46 @@
|
|
|
1
|
+
interface UseAnchorScrollOptions {
|
|
2
|
+
offset?: number | (() => number);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const useAnchorScroll = (options: UseAnchorScrollOptions = {}) => {
|
|
6
|
+
const { offset = 0 } = options;
|
|
7
|
+
|
|
8
|
+
const prefersReducedMotion = (): boolean => {
|
|
9
|
+
if (import.meta.server) return false;
|
|
10
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const resolveOffset = (): number => (typeof offset === "function" ? offset() : offset);
|
|
14
|
+
|
|
15
|
+
const scrollToAnchor = (hash: string): void => {
|
|
16
|
+
if (import.meta.server) return;
|
|
17
|
+
const id = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
18
|
+
const el = document.getElementById(id);
|
|
19
|
+
if (!el) return;
|
|
20
|
+
|
|
21
|
+
const behavior: ScrollBehavior = prefersReducedMotion() ? "instant" : "smooth";
|
|
22
|
+
const px = resolveOffset();
|
|
23
|
+
|
|
24
|
+
if (px) {
|
|
25
|
+
const top = el.getBoundingClientRect().top + window.scrollY - px;
|
|
26
|
+
window.scrollTo({ top, behavior });
|
|
27
|
+
} else {
|
|
28
|
+
el.scrollIntoView({ behavior, block: "start" });
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Intercepts anchor (#hash) clicks only. Routes and external links are left
|
|
33
|
+
// to NuxtLink/router unchanged. When reduced motion is preferred, the default
|
|
34
|
+
// browser/router anchor jump is preserved; otherwise we prevent that default
|
|
35
|
+
// and smooth-scroll ourselves.
|
|
36
|
+
const handleNavClick = (event: MouseEvent, href: string): void => {
|
|
37
|
+
if (!href.startsWith("#")) return;
|
|
38
|
+
if (prefersReducedMotion()) return;
|
|
39
|
+
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
history.pushState(null, "", href);
|
|
42
|
+
scrollToAnchor(href);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return { handleNavClick, scrollToAnchor };
|
|
46
|
+
};
|
package/app/layouts/default.vue
CHANGED
|
@@ -91,6 +91,7 @@ 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" },
|
|
94
95
|
],
|
|
95
96
|
},
|
|
96
97
|
{
|
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
@click="(e) => handleNavClick(e, `#${section.id}`)"
|
|
30
|
+
>{{ section.label }}</a>
|
|
31
|
+
</li>
|
|
32
|
+
</ul>
|
|
33
|
+
</nav>
|
|
34
|
+
</LayoutRow>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<section id="overview" class="anchor-demo-section">
|
|
38
|
+
<LayoutRow tag="div" variant="content">
|
|
39
|
+
<h2 class="page-heading-2">Overview</h2>
|
|
40
|
+
<p class="page-body-medium">
|
|
41
|
+
Single-page and anchor-linked layouts need a bridge between
|
|
42
|
+
<code class="inline-code">NuxtLink</code> (which handles routes) and the browser's
|
|
43
|
+
native anchor scrolling (which has no smooth-scroll guarantee).
|
|
44
|
+
<code class="inline-code">useAnchorScroll</code> fills that gap.
|
|
45
|
+
</p>
|
|
46
|
+
<p class="page-body-medium">
|
|
47
|
+
Pass any <code class="inline-code">#hash</code> href to
|
|
48
|
+
<code class="inline-code">handleNavClick</code> — it prevents the default jump,
|
|
49
|
+
pushes the hash into the URL, and scrolls to the matching element. Every other href
|
|
50
|
+
(routes, external URLs) is a no-op, so the same handler is safe to attach to all
|
|
51
|
+
navigation links without conditional logic in the template.
|
|
52
|
+
</p>
|
|
53
|
+
<p class="page-body-medium">
|
|
54
|
+
An optional <code class="inline-code">offset</code> shifts the final scroll position
|
|
55
|
+
upward — useful when a sticky bar would otherwise obscure the section heading. Pass a
|
|
56
|
+
number for a fixed bar height or a getter function to read the bar's live height at
|
|
57
|
+
scroll time.
|
|
58
|
+
</p>
|
|
59
|
+
</LayoutRow>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<section id="api" class="anchor-demo-section">
|
|
63
|
+
<LayoutRow tag="div" variant="content">
|
|
64
|
+
<h2 class="page-heading-2">API</h2>
|
|
65
|
+
|
|
66
|
+
<h3 class="page-heading-3">Options</h3>
|
|
67
|
+
<div class="api-table-wrapper">
|
|
68
|
+
<table class="api-table">
|
|
69
|
+
<thead>
|
|
70
|
+
<tr>
|
|
71
|
+
<th>Option</th>
|
|
72
|
+
<th>Type</th>
|
|
73
|
+
<th>Default</th>
|
|
74
|
+
<th>Description</th>
|
|
75
|
+
</tr>
|
|
76
|
+
</thead>
|
|
77
|
+
<tbody>
|
|
78
|
+
<tr>
|
|
79
|
+
<td><code class="inline-code">offset</code></td>
|
|
80
|
+
<td><code class="inline-code">number | (() => number)</code></td>
|
|
81
|
+
<td><code class="inline-code">0</code></td>
|
|
82
|
+
<td>
|
|
83
|
+
Pixels subtracted from the scroll-to position. Pass a getter to read a sticky
|
|
84
|
+
element's height at scroll time rather than at composable init.
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<h3 class="page-heading-3">Returns</h3>
|
|
92
|
+
<div class="api-table-wrapper">
|
|
93
|
+
<table class="api-table">
|
|
94
|
+
<thead>
|
|
95
|
+
<tr>
|
|
96
|
+
<th>Name</th>
|
|
97
|
+
<th>Type</th>
|
|
98
|
+
<th>Description</th>
|
|
99
|
+
</tr>
|
|
100
|
+
</thead>
|
|
101
|
+
<tbody>
|
|
102
|
+
<tr>
|
|
103
|
+
<td><code class="inline-code">handleNavClick</code></td>
|
|
104
|
+
<td><code class="inline-code">(event: MouseEvent, href: string) => void</code></td>
|
|
105
|
+
<td>
|
|
106
|
+
Attach to click handlers. No-ops silently for non-anchor hrefs so it is safe
|
|
107
|
+
on all links.
|
|
108
|
+
</td>
|
|
109
|
+
</tr>
|
|
110
|
+
<tr>
|
|
111
|
+
<td><code class="inline-code">scrollToAnchor</code></td>
|
|
112
|
+
<td><code class="inline-code">(hash: string) => void</code></td>
|
|
113
|
+
<td>
|
|
114
|
+
Programmatically scroll to a hash string (with or without the leading
|
|
115
|
+
<code class="inline-code">#</code>). Respects the same motion preference and
|
|
116
|
+
offset.
|
|
117
|
+
</td>
|
|
118
|
+
</tr>
|
|
119
|
+
</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
</div>
|
|
122
|
+
</LayoutRow>
|
|
123
|
+
</section>
|
|
124
|
+
|
|
125
|
+
<section id="usage" class="anchor-demo-section">
|
|
126
|
+
<LayoutRow tag="div" variant="content">
|
|
127
|
+
<h2 class="page-heading-2">Usage</h2>
|
|
128
|
+
|
|
129
|
+
<h3 class="page-heading-3">In TabNavigation</h3>
|
|
130
|
+
<p class="page-body-medium">
|
|
131
|
+
<code class="inline-code">TabNavigation</code> already has
|
|
132
|
+
<code class="inline-code">useAnchorScroll</code> wired up internally. Pass anchor
|
|
133
|
+
hrefs in <code class="inline-code">navItemData</code> and it works automatically.
|
|
134
|
+
</p>
|
|
135
|
+
<pre class="demo-code">const navItemData = {
|
|
136
|
+
main: [
|
|
137
|
+
{ text: "About", href: "#about" },
|
|
138
|
+
{ text: "Services", href: "#services" },
|
|
139
|
+
{ text: "Contact", href: "#contact" },
|
|
140
|
+
{ text: "Blog", href: "/blog" }, // route — handled by NuxtLink as normal
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
</pre>
|
|
144
|
+
<pre class="demo-code"><TabNavigation :nav-item-data="navItemData" />
|
|
145
|
+
</pre>
|
|
146
|
+
|
|
147
|
+
<h3 class="page-heading-3">Standalone (this page)</h3>
|
|
148
|
+
<p class="page-body-medium">
|
|
149
|
+
For custom anchor navs — like a sticky section bar or a terms-page sidebar — call
|
|
150
|
+
the composable directly. Pass a getter for
|
|
151
|
+
<code class="inline-code">offset</code> so it reads the element height at scroll
|
|
152
|
+
time rather than on mount.
|
|
153
|
+
</p>
|
|
154
|
+
<pre class="demo-code">const stickyNavRef = ref<HTMLElement | null>(null);
|
|
155
|
+
|
|
156
|
+
const { handleNavClick } = useAnchorScroll({
|
|
157
|
+
offset: () => stickyNavRef.value?.offsetHeight ?? 0,
|
|
158
|
+
});
|
|
159
|
+
</pre>
|
|
160
|
+
<pre class="demo-code"><div ref="stickyNavRef" class="sticky-nav">
|
|
161
|
+
<a href="#overview" @click="(e) => handleNavClick(e, '#overview')">Overview</a>
|
|
162
|
+
<a href="#api" @click="(e) => handleNavClick(e, '#api')">API</a>
|
|
163
|
+
</div>
|
|
164
|
+
</pre>
|
|
165
|
+
|
|
166
|
+
<h3 class="page-heading-3">Programmatic scroll</h3>
|
|
167
|
+
<p class="page-body-medium">
|
|
168
|
+
Use <code class="inline-code">scrollToAnchor</code> when you need to scroll without
|
|
169
|
+
a click event — for example, after a form submission or on route entry.
|
|
170
|
+
</p>
|
|
171
|
+
<pre class="demo-code">const { scrollToAnchor } = useAnchorScroll({ offset: 64 });
|
|
172
|
+
|
|
173
|
+
onMounted(() => {
|
|
174
|
+
if (route.hash) scrollToAnchor(route.hash);
|
|
175
|
+
});
|
|
176
|
+
</pre>
|
|
177
|
+
</LayoutRow>
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
<section id="motion" class="anchor-demo-section">
|
|
181
|
+
<LayoutRow tag="div" variant="content">
|
|
182
|
+
<h2 class="page-heading-2">Reduced Motion</h2>
|
|
183
|
+
<p class="page-body-medium">
|
|
184
|
+
When <code class="inline-code">prefers-reduced-motion: reduce</code> is active,
|
|
185
|
+
<code class="inline-code">handleNavClick</code> returns early without calling
|
|
186
|
+
<code class="inline-code">preventDefault</code>. The browser or Vue Router handles
|
|
187
|
+
the anchor navigation natively — an instant jump with no custom scroll code
|
|
188
|
+
involved. No special configuration needed.
|
|
189
|
+
</p>
|
|
190
|
+
<p class="page-body-medium">
|
|
191
|
+
To test: open your OS accessibility settings, enable Reduce Motion, then click
|
|
192
|
+
a section link in the bar above. The page will jump immediately instead of
|
|
193
|
+
scrolling.
|
|
194
|
+
</p>
|
|
195
|
+
<p class="page-body-medium">
|
|
196
|
+
<code class="inline-code">scrollToAnchor</code> respects the same preference:
|
|
197
|
+
it uses <code class="inline-code">behavior: "instant"</code> when reduced motion
|
|
198
|
+
is detected, so programmatic scrolls are equally accessible.
|
|
199
|
+
</p>
|
|
200
|
+
</LayoutRow>
|
|
201
|
+
</section>
|
|
202
|
+
</template>
|
|
203
|
+
</NuxtLayout>
|
|
204
|
+
</div>
|
|
205
|
+
</template>
|
|
206
|
+
|
|
207
|
+
<script setup lang="ts">
|
|
208
|
+
definePageMeta({ layout: false });
|
|
209
|
+
|
|
210
|
+
const stickyNavRef = ref<HTMLElement | null>(null);
|
|
211
|
+
|
|
212
|
+
const sections = [
|
|
213
|
+
{ id: "overview", label: "Overview" },
|
|
214
|
+
{ id: "api", label: "API" },
|
|
215
|
+
{ id: "usage", label: "Usage" },
|
|
216
|
+
{ id: "motion", label: "Reduced Motion" },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const { handleNavClick } = useAnchorScroll({
|
|
220
|
+
offset: () => stickyNavRef.value?.offsetHeight ?? 0,
|
|
221
|
+
});
|
|
222
|
+
</script>
|
|
223
|
+
|
|
224
|
+
<style lang="css">
|
|
225
|
+
.anchor-scroll-sticky-nav {
|
|
226
|
+
position: sticky;
|
|
227
|
+
top: 0;
|
|
228
|
+
z-index: 5;
|
|
229
|
+
background-color: var(--page-bg, #000);
|
|
230
|
+
border-block-end: 1px solid oklch(100% 0 0 / 10%);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.anchor-nav-list {
|
|
234
|
+
list-style: none;
|
|
235
|
+
margin: 0;
|
|
236
|
+
padding: 0;
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-wrap: wrap;
|
|
239
|
+
gap: 0.4rem 2rem;
|
|
240
|
+
padding-block: 1.2rem;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.anchor-nav-link {
|
|
244
|
+
color: var(--slate-02, currentColor);
|
|
245
|
+
font-size: 1.4rem;
|
|
246
|
+
letter-spacing: 0.04em;
|
|
247
|
+
text-decoration: none;
|
|
248
|
+
padding-block: 0.4rem;
|
|
249
|
+
border-block-end: 1.5px solid transparent;
|
|
250
|
+
transition: color 200ms ease, border-color 200ms ease;
|
|
251
|
+
|
|
252
|
+
&:hover,
|
|
253
|
+
&:focus-visible {
|
|
254
|
+
color: var(--slate-00, currentColor);
|
|
255
|
+
border-block-end-color: currentColor;
|
|
256
|
+
outline: none;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.anchor-demo-section {
|
|
261
|
+
min-block-size: 60vh;
|
|
262
|
+
padding-block: 6rem 8rem;
|
|
263
|
+
border-block-end: 1px solid oklch(100% 0 0 / 8%);
|
|
264
|
+
|
|
265
|
+
&:last-child {
|
|
266
|
+
border-block-end: none;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.page-heading-2 {
|
|
270
|
+
margin-block-end: 2rem;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.page-heading-3 {
|
|
274
|
+
margin-block: 3.2rem 1.2rem;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.page-body-medium {
|
|
278
|
+
margin-block-end: 1.6rem;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.api-table-wrapper {
|
|
283
|
+
overflow-x: auto;
|
|
284
|
+
margin-block: 1.6rem 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.api-table {
|
|
288
|
+
width: 100%;
|
|
289
|
+
border-collapse: collapse;
|
|
290
|
+
font-size: 1.4rem;
|
|
291
|
+
line-height: 1.5;
|
|
292
|
+
|
|
293
|
+
th,
|
|
294
|
+
td {
|
|
295
|
+
text-align: left;
|
|
296
|
+
padding: 1rem 1.6rem;
|
|
297
|
+
border: 1px solid oklch(100% 0 0 / 12%);
|
|
298
|
+
vertical-align: top;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
th {
|
|
302
|
+
background-color: oklch(100% 0 0 / 4%);
|
|
303
|
+
font-weight: 600;
|
|
304
|
+
color: var(--slate-01, currentColor);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
td {
|
|
308
|
+
color: var(--slate-02, currentColor);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.demo-code {
|
|
313
|
+
background-color: oklch(100% 0 0 / 4%);
|
|
314
|
+
border: 1px solid oklch(100% 0 0 / 10%);
|
|
315
|
+
border-radius: 0.6rem;
|
|
316
|
+
padding: 1.8rem 2rem;
|
|
317
|
+
overflow-x: auto;
|
|
318
|
+
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
319
|
+
font-size: 1.3rem;
|
|
320
|
+
line-height: 1.65;
|
|
321
|
+
margin-block: 1.2rem 2rem;
|
|
322
|
+
white-space: pre;
|
|
323
|
+
color: var(--slate-01, currentColor);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.inline-code {
|
|
327
|
+
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
328
|
+
font-size: 0.875em;
|
|
329
|
+
background-color: oklch(100% 0 0 / 8%);
|
|
330
|
+
padding: 0.15em 0.4em;
|
|
331
|
+
border-radius: 0.3rem;
|
|
332
|
+
}
|
|
333
|
+
</style>
|