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.
@@ -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
- - `loading="lazy"` and `decoding="async"` are hardcoded on the `<img>`. If this component is the LCP image, override with `loading="eager"` via a CSS-only approach is not possible use `ScrollRevealFrame` with a manual `NuxtImg` instead and set `:loading="'eager'"`.
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.
@@ -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="lazy"
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>
@@ -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="lazy" decoding="async" src="/_ipx/s_800x600/images/test.jpg"></div>
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.
@@ -100,11 +100,6 @@ watch(
100
100
  }
101
101
 
102
102
  .profile-info {
103
- display: flex;
104
- flex-direction: column;
105
- gap: 1.5rem;
106
- height: stretch;
107
-
108
103
  .profile-info-content {
109
104
  .profile-info-block {
110
105
  margin-block-end: 1.5rem;
@@ -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
+ };
@@ -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 | (() =&gt; 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) =&gt; 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) =&gt; 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">&lt;TabNavigation :nav-item-data="navItemData" /&gt;
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&lt;HTMLElement | null&gt;(null);
155
+
156
+ const { handleNavClick } = useAnchorScroll({
157
+ offset: () =&gt; stickyNavRef.value?.offsetHeight ?? 0,
158
+ });
159
+ </pre>
160
+ <pre class="demo-code">&lt;div ref="stickyNavRef" class="sticky-nav"&gt;
161
+ &lt;a href="#overview" @click="(e) =&gt; handleNavClick(e, '#overview')"&gt;Overview&lt;/a&gt;
162
+ &lt;a href="#api" @click="(e) =&gt; handleNavClick(e, '#api')"&gt;API&lt;/a&gt;
163
+ &lt;/div&gt;
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(() =&gt; {
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>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.46",
4
+ "version": "9.1.48",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",