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.
@@ -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 &#124; 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.
@@ -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) => href === route.path;
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,
@@ -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">&lt;a&gt;</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">&lt;a&gt;</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">&lt;div ref="stickyNavRef"&gt;
69
+ &lt;TabNavigation
70
+ :nav-item-data="navItemData"
71
+ :anchor-scroll-offset="() => stickyNavRef?.offsetHeight ?? 0"
72
+ /&gt;
73
+ &lt;/div&gt;</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">&lt;a&gt;</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 &lt;a&gt;
94
+ { text: "Services", href: "#services" }, // anchor — plain &lt;a&gt;
95
+ { text: "Blog", href: "/blog" }, // route — NuxtLink
96
+ { text: "Contact", href: "#contact" }, // anchor — plain &lt;a&gt;
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 | (() =&gt; 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) =&gt; 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) =&gt; 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">&lt;TabNavigation :nav-item-data="navItemData" /&gt;
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&lt;HTMLElement | null&gt;(null);
156
+
157
+ const { handleNavClick } = useAnchorScroll({
158
+ offset: () =&gt; stickyNavRef.value?.offsetHeight ?? 0,
159
+ });
160
+ </pre>
161
+ <pre class="demo-code">&lt;div ref="stickyNavRef" class="sticky-nav"&gt;
162
+ &lt;a href="#overview" @click="(e) =&gt; handleNavClick(e, '#overview')"&gt;Overview&lt;/a&gt;
163
+ &lt;a href="#api" @click="(e) =&gt; handleNavClick(e, '#api')"&gt;API&lt;/a&gt;
164
+ &lt;/div&gt;
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(() =&gt; {
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>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.47",
4
+ "version": "9.1.49",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",