Skip to content

Commit 88f06af

Browse files
committed
restore boundOffset for jumpToFirst, improve watchers behav w/ customRef
1 parent 04c988e commit 88f06af

File tree

4 files changed

+78
-66
lines changed

4 files changed

+78
-66
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ html {
258258
You are totally free to create your own click handler and choose the scrolling strategy: CSS (smooth or auto), [scrollIntoView](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) or even a scroll library like [animated-scroll-to](https://github.com/Stanko/animated-scroll-to) with custom easings will work. Just remember to include `setActive` in your handler.
259259

260260
<details><summary><strong>RouterLink / NuxtLink</strong></summary>
261+
261262
<br />
262263

263264
Since those links don't navigate to another page, you can safely use an `a` tag with a `href` attribute and avoid to use `RouterLink` or `NuxtLink`.
@@ -410,6 +411,8 @@ const router = createRouter({
410411
})
411412
```
412413

414+
> :bulb: No need to set overlayHeight if using `scrollIntoView` as the method reads the `scroll-margin-top` target property.
415+
413416
<br />
414417

415418
## License

demo/components/Sidebar/TOC.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ const { clickType } = inject('DemoRadios') as {
1919
const { activeIndex, activeId, setActive, isActive } = useActive(targets, {
2020
rootId,
2121
overlayHeight,
22-
minWidth: 0,
22+
minWidth: 610,
23+
jumpToFirst: false,
24+
jumpToLast: false,
25+
boundaryOffset: {
26+
toTop: 100,
27+
},
2328
});
2429
2530
const activeItemHeight = computed(

src/useActive.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ref, Ref, onMounted, computed, unref, watch, isRef, isReactive } from 'vue';
1+
import { ref, Ref, onMounted, computed, unref, watch, isRef, isReactive, reactive } from 'vue';
22
import { useListeners } from './useListeners';
33
import { getEdges, getRects, FIXED_TO_TOP_OFFSET } from './utils';
44

@@ -58,7 +58,7 @@ export function useActive(
5858
const root = ref<HTMLElement | null>(null);
5959
const rootTop = ref(0);
6060
const targets = ref<HTMLElement[]>([]);
61-
const isHTML = computed(() => rootId == null);
61+
const isHTML = computed(() => typeof rootId !== 'string');
6262
const ids = computed(() => targets.value.map(({ id }) => id));
6363

6464
// Returned values
@@ -132,9 +132,8 @@ export function useActive(
132132
const firstIn = getRects(targets.value, 'IN', offset).keys().next().value ?? '';
133133

134134
if (!jumpToFirst && firstIn === ids.value[0]) {
135-
const firstTarget = getRects(targets.value, 'ALL').values().next().value;
136-
// Exclude boundaryOffsets on first target when jumpToFirst is false
137-
if (firstTarget > FIXED_TO_TOP_OFFSET + (offset - toTop!)) {
135+
const firstTargetTop = getRects(targets.value, 'ALL').values().next().value;
136+
if (firstTargetTop > FIXED_TO_TOP_OFFSET + offset) {
138137
return (activeId.value = '');
139138
}
140139
}

src/useListeners.ts

Lines changed: 65 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { watch, onMounted, ref, Ref, ComputedRef, onBeforeUnmount, computed, nextTick } from 'vue';
1+
import { watch, onMounted, ref, Ref, ComputedRef, onBeforeUnmount, computed, customRef } from 'vue';
22
import { isSSR } from './utils';
33

44
type UseListenersOptions = {
@@ -9,61 +9,52 @@ type UseListenersOptions = {
99
minWidth: number;
1010
};
1111

12+
const ONCE = { once: true };
13+
1214
export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: UseListenersOptions) {
13-
const isClick = ref(false);
15+
const isClick = customRef<boolean>((track, trigger) => {
16+
let value = false;
17+
return {
18+
get() {
19+
track();
20+
return value;
21+
},
22+
set(newValue) {
23+
value = matchMedia.value ? newValue : false;
24+
trigger();
25+
},
26+
};
27+
});
1428

1529
if (isSSR) {
1630
return isClick;
1731
}
1832

1933
const media = `(min-width: ${minWidth}px)`;
20-
2134
const matchMedia = ref(window.matchMedia(media).matches);
2235
const isIdle = ref(false);
23-
24-
const clickY = computed(() => {
25-
if (isClick.value) {
26-
return getNextY();
27-
}
28-
});
36+
const clickY = computed(() => (isClick.value ? getNextY() : 0));
2937

3038
let prevY: number;
3139

3240
function getNextY() {
3341
return isHTML.value ? window.scrollY : root.value!.scrollTop;
3442
}
3543

36-
function onResize() {
37-
rootTop.value = isHTML.value ? 0 : root.value!.getBoundingClientRect().top;
38-
matchMedia.value = window.matchMedia(media).matches;
39-
}
40-
41-
function onScroll() {
42-
// Do not update results if scrolling from click
43-
if (!isClick.value) {
44-
const nextY = getNextY();
45-
if (!prevY) {
46-
prevY = nextY;
47-
}
48-
_setActive(prevY);
49-
prevY = nextY;
50-
}
51-
}
52-
5344
function setReady() {
54-
let prevY: number;
45+
let rafPrevY: number;
5546
let rafId: DOMHighResTimeStamp;
5647
let frameCount = 0;
5748

5849
function scrollEnd() {
59-
const nextY = getNextY();
60-
if (typeof prevY === 'undefined' || prevY !== nextY) {
50+
const rafNextY = getNextY();
51+
if (typeof rafPrevY === 'undefined' || rafPrevY !== rafNextY) {
6152
frameCount = 0;
62-
prevY = nextY;
53+
rafPrevY = rafNextY;
6354
// console.log('Scrolling...');
6455
return requestAnimationFrame(scrollEnd);
6556
}
66-
// When equal, wait at least 20 frames to be sure is idle
57+
// When equal, wait for 20 frames to be sure is idle
6758
frameCount++;
6859
if (frameCount === 20) {
6960
isIdle.value = true;
@@ -78,6 +69,23 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
7869
rafId = requestAnimationFrame(scrollEnd);
7970
}
8071

72+
function onResize() {
73+
rootTop.value = isHTML.value ? 0 : root.value!.getBoundingClientRect().top;
74+
matchMedia.value = window.matchMedia(media).matches;
75+
}
76+
77+
function onScroll() {
78+
// Do not "update" results if scrolling from click
79+
if (!isClick.value) {
80+
const nextY = getNextY();
81+
if (!prevY) {
82+
prevY = nextY;
83+
}
84+
_setActive(prevY);
85+
prevY = nextY;
86+
}
87+
}
88+
8189
// Restore main listener "updating" functionalities if scrolling again while scrolling from click...
8290
function reScroll() {
8391
isClick.value = false;
@@ -90,19 +98,23 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
9098
}
9199

92100
function onPointerDown(event: PointerEvent) {
93-
reScroll();
94-
const { tagName } = event.target as HTMLElement;
95-
const isLink = tagName === 'A' || tagName === 'BUTTON';
96-
if (!isLink) {
101+
const isLink = (event.target as HTMLElement).tagName === 'A';
102+
const hasLink = (event.target as HTMLElement).closest('a');
103+
if (!isLink && !hasLink) {
104+
reScroll();
97105
// ...and force set if canceling scroll
98106
_setActive(clickY.value!, { isCancel: true });
99107
}
100108
}
101109

102110
onMounted(() => {
103111
window.addEventListener('resize', onResize, { passive: true });
104-
// Wait for any eventual scroll to hash triggered by browser to end
105-
setReady();
112+
if (matchMedia.value) {
113+
// Wait for any eventual scroll to hash triggered by browser to end
114+
setReady();
115+
} else {
116+
isIdle.value = true;
117+
}
106118
});
107119

108120
onBeforeUnmount(() => {
@@ -111,21 +123,18 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
111123

112124
watch(
113125
[isIdle, matchMedia, root],
114-
([isIdle, matchMedia, root], [], onCleanup) => {
126+
([_isIdle, _matchMedia, root], [], onCleanup) => {
115127
const rootEl = isHTML.value ? document : root;
116-
if (isIdle && rootEl) {
117-
if (matchMedia) {
118-
console.log('Adding main listener...');
119-
rootEl.addEventListener('scroll', onScroll, {
120-
passive: true,
121-
});
122-
}
128+
129+
if (_isIdle && rootEl && _matchMedia) {
130+
console.log('Adding main listener...');
131+
rootEl.addEventListener('scroll', onScroll, {
132+
passive: true,
133+
});
123134

124135
onCleanup(() => {
125-
if (matchMedia) {
126-
console.log('Removing main listener...');
127-
rootEl.removeEventListener('scroll', onScroll);
128-
}
136+
console.log('Removing main listener...');
137+
rootEl.removeEventListener('scroll', onScroll);
129138
});
130139
}
131140
},
@@ -134,23 +143,19 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
134143

135144
watch(
136145
isClick,
137-
(isClick, _, onCleanup) => {
146+
(_isClick, _, onCleanup) => {
138147
const rootEl = isHTML.value ? document : root.value!;
139148

140-
if (isClick) {
149+
if (_isClick) {
141150
console.log('Adding additional listeners...');
142-
rootEl.addEventListener('scroll', setReady, { once: true });
143-
rootEl.addEventListener('wheel', reScroll, { once: true });
144-
rootEl.addEventListener('keydown', onSpaceBar as EventListener, {
145-
once: true,
146-
});
147-
rootEl.addEventListener('pointerdown', onPointerDown as EventListener, {
148-
once: true,
149-
});
151+
rootEl.addEventListener('scroll', setReady, ONCE);
152+
rootEl.addEventListener('wheel', reScroll, ONCE);
153+
rootEl.addEventListener('keydown', onSpaceBar as EventListener, ONCE);
154+
rootEl.addEventListener('pointerdown', onPointerDown as EventListener, ONCE);
150155
}
151156

152157
onCleanup(() => {
153-
if (isClick) {
158+
if (_isClick) {
154159
console.log('Removing additional listeners...');
155160
rootEl.removeEventListener('scroll', setReady);
156161
rootEl.removeEventListener('wheel', reScroll);

0 commit comments

Comments
 (0)