Skip to content

Commit 64975c5

Browse files
committed
release v0.9.7
1 parent 2d22277 commit 64975c5

File tree

10 files changed

+395
-265
lines changed

10 files changed

+395
-265
lines changed

README.md

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414
The [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) is a great API.
1515
But it may not be the one-size-fits-all solution to highlight menu/sidebar links.
1616

17-
You may noticed that's tricky to customize behavior according to different interactions.
18-
19-
For example, you want to immediately highlight targets when scroll is originated from click/navigation but not when it is originated from wheel/touch. You also want to highlight any clicked link even if it will never intersect.
17+
When smooth-scrolling, you may want to immediately highlight targets when scroll is originated from click/navigation but not when it is originated from wheel/touch. You may also want to highlight any clicked link even if it will never intersect.
2018

2119
**Vue Use Active Scroll** implements a custom scroll observer which automatically adapts to any type of scroll behaviors and interactions and always returns the "correct" active target.
2220

@@ -26,12 +24,14 @@ For example, you want to immediately highlight targets when scroll is originated
2624
- CSS scroll-behavior or JS scroll agnostic
2725
- Adaptive behavior on mount, back/forward hash navigation, scroll, click, cancel.
2826
- Customizable boundary offsets for each direction
27+
- Customizable offsets for first/last targets
2928
- Customizable behavior on top/bottom reached
30-
- Supports containers different than window
29+
- Supports custom scrolling containers
3130

3231
### What it doesn't do?
3332

3433
- Mutate elements and inject styles
34+
- Force specific scroll behavior / callbacks
3535
- Scroll to targets
3636

3737
<br />
@@ -62,11 +62,9 @@ Assuming your content looks like:
6262
And your links look like:
6363

6464
```html
65-
<nav>
66-
<a href="#introduction">Introduction</a>
67-
<a href="#quick-start">Quick Start</a>
68-
<a href="#props">Props</a>
69-
</nav>
65+
<a href="#introduction">Introduction</a>
66+
<a href="#quick-start">Quick Start</a>
67+
<a href="#props">Props</a>
7068
```
7169

7270
In your menu/sidebar component, provide the IDs to observe to `useActive` (order is not
@@ -78,7 +76,7 @@ important).
7876
<script setup>
7977
import { useActive } from 'vue-use-active-scroll'
8078
81-
// Data used to render your links
79+
// Data to render links
8280
const links = ref([
8381
{ href: 'introduction', label: 'Introduction' },
8482
{ href: 'quick-start', label: 'Quick Start' },
@@ -92,6 +90,8 @@ const { isActive } = useActive(targets)
9290
</script>
9391
```
9492

93+
You can provide either a reactive or a plain array of strings. If the array is reactive, the observer will reinitialize whenever it changes.
94+
9595
> :bulb: For a TOC, you want to target (and scroll) the headings of your sections (instead of the whole section) to ensure results better-aligned with users' reading flow.
9696
9797
<details><summary><strong>Nuxt Content 2</strong></summary>
@@ -144,7 +144,7 @@ const { isActive } = useActive(targets)
144144

145145
<br />
146146

147-
## 2. Configure the composable (optional)
147+
## 2. Customize the composable (optional)
148148

149149
`useActive` accepts an optional configuration object as its second argument:
150150

@@ -154,15 +154,16 @@ const { isActive, setActive } = useActive(targets, {
154154
})
155155
```
156156

157-
| Property | Type | Default | Description |
158-
| -------------- | ------------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
159-
| jumpToFirst | `boolean` | true | Whether to set the first target on mount as active even if not (yet) intersecting. |
160-
| jumpToLast | `boolean` | true | Whether to set the last target as active once reached the bottom even if previous targets are entirely visible. |
161-
| boundaryOffset | `BoundaryOffset` | { toTop: 0, toBottom: 0 } | Boundary offset in px for each scroll direction. Tweak them to "anticipate" or "delay" target detection. |
162-
| rootId | `string` \| `null` | null | Id of the scrolling element. Set it only if your content **is not scrolled** by the window. |
163-
| replaceHash | `boolean` | false | Whether to replace URL hash on scroll. First target is ignored if `jumpToFirst` is true. |
164-
| overlayHeight | `number` | 0 | Height in pixels of any **CSS fixed** content that overlaps the top of your scrolling area (e.g. fixed header). Must be paired with a CSS [scroll-margin-top](#setting-scroll-margin-top-for-fixed-headers) rule. |
165-
| minWidth | `number` | 0 | Whether to toggle listeners and functionalities within a specific width. Useful if hiding the sidebar using `display: none`. |
157+
| Property | Type | Default | Description |
158+
| -------------- | --------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
159+
| jumpToFirst | `boolean` | true | Whether to set the first target on mount as active even if not (yet) intersecting. |
160+
| jumpToLast | `boolean` | true | Whether to set the last target as active once reached the bottom even if previous targets are entirely visible. |
161+
| boundaryOffset | `BoundaryOffset` | { toTop: 0, toBottom: 0 } | Boundary offset in px for each scroll direction. Tweak them to "anticipate" or "delay" target detection. |
162+
| edgeOffset | `EdgeOffset` | { first: 100, last: -100 } | Offset in px for fist and last target. `first` has no effect if `jumpToFirst` is true. Same for `last` if `jumpToLast` is true. |
163+
| root | `HTMLElement \| null` \| `Ref<HTMLElement \| null>` | null | Scrolling element. Set it only if your content **is not scrolled** by the window. If _null_, defaults to documentElement. |
164+
| replaceHash | `boolean` | false | Whether to replace URL hash on scroll. First target is ignored if `jumpToFirst` is true. |
165+
| overlayHeight | `number` | 0 | Height in pixels of any **CSS fixed** content that overlaps the top of your scrolling area (e.g. fixed header). Must be paired with a CSS [scroll-margin-top](#setting-scroll-margin-top-for-fixed-headers) rule. |
166+
| minWidth | `number` | 0 | Whether to toggle listeners and functionalities within a specific width. Useful if hiding the sidebar using `display: none`. |
166167

167168
### Return object
168169

@@ -208,7 +209,7 @@ const { isActive, setActive } = useActive(targets)
208209

209210
You're free to choose between CSS (smooth or auto), [scrollIntoView](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) or even a library like [animated-scroll-to](https://github.com/Stanko/animated-scroll-to).
210211

211-
#### A. Using CSS scroll-behavior
212+
#### A. Using native CSS scroll-behavior
212213

213214
- If content is scrolled by the window, add the following CSS rule to your `html` element:
214215

@@ -279,6 +280,7 @@ function scrollTo(event, id) {
279280
```vue
280281
<script setup>
281282
// ...
283+
282284
const { isActive, setActive } = useActive(targets)
283285
</script>
284286
@@ -367,7 +369,7 @@ useActive(targets, { overlayHeight: 100 })
367369

368370
<br />
369371

370-
## Vue Router Scroll Hash Navigation
372+
## Vue Router - Scroll to hash onMount / navigation
371373

372374
> :warning: If using Nuxt 3, Vue Router is already configured to scroll to and from URL hash on page load or back/forward navigation. **So you don't need to do follow the steps below**. Otherwise rules must be defined manually.
373375
@@ -399,7 +401,7 @@ const router = createRouter({
399401
400402
> :bulb: There's no need need to set overlayHeight if using `scrollIntoView` as the method is aware of target's `scroll-margin-top` property.
401403
402-
### Scrolling from hash to the top of the page
404+
### Scrolling from hash back to the top of the page
403405

404406
To navigate back to the top of the same page (e.g. clicking on browser back button from a hash to the page root), use the _scroll_ method for containers and return _top_ for content scrolled by the window.
405407

@@ -413,7 +415,7 @@ const router = createRouter({
413415
to.name === 'PageNameUsingContainer' &&
414416
from.name === 'PageNameUsingContainer'
415417
) {
416-
return document.querySelector('#ScrollingContainer').scroll(0, 0)
418+
return document.getElementById('ScrollingContainer').scroll(0, 0)
417419
}
418420

419421
// Content scrolled by the window
@@ -425,6 +427,55 @@ const router = createRouter({
425427

426428
<br />
427429

430+
## Vue Router - Prevent hash from being pushed
431+
432+
You may noticed that when clicking on a link, a new entry is added to the history. When navigating back, the page will scroll to the previous target and so on.
433+
434+
If you don't like that, choose to replace instead of pushing the hash:
435+
436+
```vue
437+
<template>
438+
<!-- ... -->
439+
<RouterLink
440+
@click.native="setActive(link.href)"
441+
:to="{ hash: `#${item.href}`, replace: true /* 👈🏻 */ }"
442+
:class="{
443+
active: isActive(link.href)
444+
}"
445+
/>
446+
<!-- ... -->
447+
</template>
448+
```
449+
450+
<br />
451+
452+
## Custom initialization / re-initialization
453+
454+
If the targets array is empty, _useActive_ won't initialize the scroll observer.
455+
456+
Whenever `root` or `targets` are updated (and not empty), _useActive_ will re-initialize the observer.
457+
458+
```vue
459+
<script setup>
460+
// ...
461+
462+
const targets = ref([])
463+
const root = ref(null)
464+
465+
const { isActive, setActive } = useActive(targets) // Nothing is initialized
466+
467+
watch(someReactiveValue, async (newValue) => {
468+
await someAsyncFunction()
469+
470+
// Whenever ready, update targets or root and init
471+
targets.value = ['id-1', 'id-2', 'id-3']
472+
root.value = document.getElementById('MyContainer')
473+
})
474+
</script>
475+
```
476+
477+
<br />
478+
428479
## License
429480

430481
MIT

demo/components/Sidebar/DemoControls.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts" setup>
2-
import { inject, watch, Ref } from 'vue';
2+
import { inject, watch, type Ref } from 'vue';
33
44
const { shiftSection, pushSection } = inject('DemoButtons') as {
55
shiftSection: () => void;

demo/components/Sidebar/TOC.vue

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,26 @@
11
<script lang="ts" setup>
2-
import { computed, ComputedRef, inject } from 'vue';
2+
import { computed, inject, type ComputedRef, type Ref } from 'vue';
33
import { useRoute } from 'vue-router';
44
import animateScrollTo from 'animated-scroll-to';
55
import { useActive } from '../../../src/useActive';
66
77
type TOCData = {
88
menuItems: { label: string; href: string }[];
99
targets: ComputedRef<string[]>;
10-
rootId?: string | null;
10+
containerRef: Ref<HTMLElement | null>;
1111
overlayHeight?: number;
1212
};
1313
14-
const { menuItems, targets, rootId = null, overlayHeight = 0 } = inject('TOCData') as TOCData;
14+
const { menuItems, targets, containerRef, overlayHeight = 0 } = inject('TOCData') as TOCData;
1515
1616
const { clickType } = inject('DemoRadios') as {
1717
clickType: ComputedRef<'native' | 'custom'>;
1818
};
1919
2020
const { activeIndex, activeId, setActive, isActive } = useActive(targets, {
21-
rootId,
21+
root: containerRef,
2222
overlayHeight,
2323
replaceHash: true,
24-
/* boundaryOffset: {
25-
toBottom: 100,
26-
toTop: -100,
27-
}, */
2824
});
2925
3026
const route = useRoute();
@@ -36,7 +32,7 @@ const activeItemHeight = computed(
3632
function customScroll(id: string) {
3733
setActive(id);
3834
animateScrollTo(document.getElementById(id) as HTMLElement, {
39-
elementToScroll: rootId ? (document.getElementById(rootId) as HTMLElement) : window,
35+
elementToScroll: containerRef?.value ?? window,
4036
easing: (x: number) => 1 + (1.70158 + 1) * Math.pow(x - 1, 3) + 1.70158 * Math.pow(x - 1, 2),
4137
maxDuration: 600,
4238
verticalOffset: -overlayHeight || 0,
@@ -56,7 +52,7 @@ const onClick = computed(() => (clickType.value === 'native' ? setActive : custo
5652
<RouterLink
5753
@click.native="onClick(item.href)"
5854
:ariaCurrentValue="`${isActive(item.href)}`"
59-
:to="{ hash: `#${item.href}` }"
55+
:to="{ hash: `#${item.href}` /* , replace: true */ }"
6056
:class="{
6157
Active: isActive(item.href),
6258
}"

demo/pages/Container.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
<script setup lang="ts">
2-
import { computed, provide } from 'vue';
2+
import { computed, provide, ref } from 'vue';
33
import PageLayout from './_Layout.vue';
44
import { useFakeData } from '../useFakeData';
55
66
const { menuItems, sections, /* Demo purposes => */ pushSection, shiftSection } = useFakeData();
77
88
const targets = computed<string[]>(() => sections.map((section) => section.id));
99
10-
provide('TOCData', { menuItems, targets, rootId: 'ScrollingContainer' }); // Injected to TOC.vue
10+
const containerRef = ref<HTMLElement | null>(null);
11+
12+
provide('TOCData', { menuItems, targets, containerRef }); // Injected to TOC.vue
1113
provide('DemoButtons', { pushSection, shiftSection }); // Injected to DemoControls.vue
1214
</script>
1315

1416
<template>
1517
<PageLayout>
16-
<div id="ScrollingContainer">
18+
<div ref="containerRef" class="Container">
1719
<section v-for="section in sections" :key="section.id">
1820
<h2 :id="section.id">
1921
{{ section.title }}
@@ -25,7 +27,7 @@ provide('DemoButtons', { pushSection, shiftSection }); // Injected to DemoContro
2527
</template>
2628

2729
<style scoped>
28-
#ScrollingContainer {
30+
.Container {
2931
overflow: auto;
3032
max-height: calc(100vh - 160px);
3133
scroll-behavior: var(--ScrollBehavior);

demo/useFakeData.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export function useFakeData(length = 10) {
1818
}))
1919
);
2020

21-
const lastNum = computed(() => parseInt(sections[sections.length - 1].title));
22-
const firstNum = computed(() => parseInt(sections[0].title));
21+
const lastNum = computed(() => parseInt(sections[sections.length - 1]?.title ?? '-1'));
22+
const firstNum = computed(() => parseInt(sections[0]?.title ?? '-1'));
2323

2424
const menuItems = computed(() =>
2525
sections.map((item) => ({

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vue-use-active-scroll",
3-
"version": "0.9.6",
3+
"version": "0.9.7",
44
"private": false,
55
"description": "Reactive and accurate TOC/sidebar links without compromises for Vue 3.",
66
"keywords": [
@@ -49,20 +49,20 @@
4949
"*.{ts,vue,md}": "prettier --write"
5050
},
5151
"devDependencies": {
52-
"@rollup/plugin-terser": "^0.3.0",
52+
"@rollup/plugin-terser": "^0.4.0",
5353
"@types/node": "^18.11.18",
54-
"@typescript-eslint/eslint-plugin": "^5.48.2",
55-
"@typescript-eslint/parser": "^5.48.2",
54+
"@typescript-eslint/eslint-plugin": "^5.49.0",
55+
"@typescript-eslint/parser": "^5.49.0",
5656
"@vitejs/plugin-vue": "^4.0.0",
5757
"animated-scroll-to": "^2.3.0",
58-
"cypress": "^12.3.0",
59-
"eslint": "^8.32.0",
58+
"cypress": "^12.4.1",
59+
"eslint": "^8.33.0",
6060
"eslint-plugin-vue": "^9.9.0",
6161
"husky": "^8.0.3",
62-
"playwright-webkit": "^1.29.2",
62+
"playwright-webkit": "^1.30.0",
6363
"prettier": "^2.8.3",
64-
"rimraf": "^4.1.1",
65-
"typescript": "^4.9.4",
64+
"rimraf": "^4.1.2",
65+
"typescript": "^4.9.5",
6666
"vite": "^4.0.4",
6767
"vite-plugin-dts": "^1.7.1",
6868
"vue": "^3.2.45",

0 commit comments

Comments
 (0)