Skip to content

Commit c80d6e8

Browse files
committed
add proper resize functionalities toggle
1 parent 88f06af commit c80d6e8

File tree

5 files changed

+113
-84
lines changed

5 files changed

+113
-84
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,11 @@ const { isActive, setActive } = useActive(targets, {
205205
| -------------- | ------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
206206
| jumpToFirst | `boolean` | true | Whether to set the first target on mount as active even if not (yet) intersecting. |
207207
| jumpToLast | `boolean` | true | Whether to set the last target as active once reached the bottom. |
208-
| boundaryOffset | `BoundaryOffset` | { toTop: 0, toBottom: 0 } | Boundary offset in px for each scroll direction. Tweak them to "anticipate" or "delay" targets detection. |
208+
| boundaryOffset | `BoundaryOffset` | { toTop: 0, toBottom: 0 } | Boundary offset in px for each scroll direction. Tweak them to "anticipate" or "delay" target detection. |
209209
| rootId | `string` \| `null` | null | Id of the scrolling element. Set it only if your content **is not scrolled** by the window. |
210210
| replaceHash | `boolean` | false | Whether to replace URL hash on scroll. First target is ignored if `jumpToFirst` is true. |
211211
| 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]() rule. |
212-
| minWidth | `number` | 0 | Viewport width in px from which scroll listeners should be toggled. Useful if hiding the sidebar with `display: none` within a specific width. |
212+
| minWidth | `number` | 0 | Whether to toggle listeners and functionalities within a specific width. Useful if hiding the sidebar using `display: none`. |
213213

214214
### Return object
215215

@@ -342,7 +342,7 @@ html {
342342
</style>
343343
```
344344

345-
> :bulb: If you're playing with transitions or advanced styling rules simply use of _activeIndex_ and _activeId_.
345+
> :bulb: If you're playing with transitions or advanced styling rules simply leverage _activeIndex_ and _activeId_.
346346
347347
<br />
348348

@@ -411,7 +411,7 @@ const router = createRouter({
411411
})
412412
```
413413

414-
> :bulb: No need to set overlayHeight if using `scrollIntoView` as the method reads the `scroll-margin-top` target property.
414+
> :bulb: No need to set overlayHeight if using `scrollIntoView` as the method is aware of target's `scroll-margin-top` property.
415415
416416
<br />
417417

demo/components/Sidebar/TOC.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ const { clickType } = inject('DemoRadios') as {
1919
const { activeIndex, activeId, setActive, isActive } = useActive(targets, {
2020
rootId,
2121
overlayHeight,
22-
minWidth: 610,
23-
jumpToFirst: false,
24-
jumpToLast: false,
22+
minWidth: 0,
23+
/* jumpToFirst: false,
24+
jumpToLast: false, */
2525
boundaryOffset: {
2626
toTop: 100,
2727
},
@@ -44,7 +44,7 @@ function customScroll(id: string) {
4444
}
4545
4646
watch(activeId, (newId) => {
47-
console.log('activeId', newId);
47+
/* console.log('activeId', newId); */
4848
});
4949
5050
const onClick = computed(() => (clickType.value === 'native' ? setActive : customScroll));

src/useActive.ts

Lines changed: 60 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
import { ref, Ref, onMounted, computed, unref, watch, isRef, isReactive, reactive } from 'vue';
2-
import { useListeners } from './useListeners';
3-
import { getEdges, getRects, FIXED_TO_TOP_OFFSET } from './utils';
1+
import {
2+
ref,
3+
Ref,
4+
onMounted,
5+
computed,
6+
unref,
7+
watch,
8+
isRef,
9+
isReactive,
10+
onBeforeUnmount,
11+
} from 'vue';
12+
import { useScroll } from './useScroll';
13+
import { getEdges, getRects, useRestrictedRef, isSSR, FIXED_TO_TOP_OFFSET } from './utils';
414

515
type UseActiveTitleOptions = {
616
jumpToFirst?: boolean;
@@ -54,15 +64,19 @@ export function useActive(
5464
} = defaultOpts.boundaryOffset,
5565
}: UseActiveTitleOptions = defaultOpts
5666
): UseActiveTitleReturn {
67+
const media = `(min-width: ${minWidth}px)`;
68+
5769
// Internal
5870
const root = ref<HTMLElement | null>(null);
5971
const rootTop = ref(0);
6072
const targets = ref<HTMLElement[]>([]);
73+
const matchMedia = ref(isSSR || window.matchMedia(media).matches);
74+
6175
const isHTML = computed(() => typeof rootId !== 'string');
6276
const ids = computed(() => targets.value.map(({ id }) => id));
6377

6478
// Returned values
65-
const activeId = ref('');
79+
const activeId = useRestrictedRef(matchMedia, '');
6680
const activeIndex = computed(() => ids.value.indexOf(activeId.value));
6781

6882
// Runs onMount and whenever the user array changes
@@ -85,7 +99,8 @@ export function useActive(
8599

86100
if (isTop && jumpToFirst) {
87101
return (activeId.value = ids.value[0]), true;
88-
} else if (isBottom && jumpToLast) {
102+
}
103+
if (isBottom && jumpToLast) {
89104
return (activeId.value = ids.value.at(-1)!), true;
90105
}
91106
}
@@ -108,15 +123,16 @@ export function useActive(
108123
}
109124
}
110125

111-
// Sets first target that left the top of the viewport
126+
// Sets first target that LEFT the top
112127
function onScrollDown({ isCancel } = { isCancel: false }) {
113-
// OverlayHeight not needed as 'scroll-margin-top' should be set instead.
128+
// overlayHeight not needed as 'scroll-margin-top' is set instead
114129
const offset = rootTop.value + toBottom!;
115130

116131
const firstOut =
117132
[...getRects(targets.value, 'OUT', offset).keys()].at(-1) ??
118133
(jumpToFirst ? ids.value[0] : '');
119134

135+
// Prevent innatural highlighting with smoothscroll/custom easings
120136
if (ids.value.indexOf(firstOut) > ids.value.indexOf(activeId.value)) {
121137
return (activeId.value = firstOut);
122138
}
@@ -126,7 +142,7 @@ export function useActive(
126142
}
127143
}
128144

129-
// Sets first target that entered the top of the viewport
145+
// Sets first target that ENTERED the top
130146
function onScrollUp() {
131147
const offset = overlayHeight + rootTop.value + toTop!;
132148
const firstIn = getRects(targets.value, 'IN', offset).keys().next().value ?? '';
@@ -143,6 +159,11 @@ export function useActive(
143159
}
144160
}
145161

162+
function onResize() {
163+
rootTop.value = isHTML.value ? 0 : root.value!.getBoundingClientRect().top;
164+
matchMedia.value = window.matchMedia(media).matches;
165+
}
166+
146167
// Returned
147168
function setActive(id: string) {
148169
if (id !== activeId.value) {
@@ -171,24 +192,43 @@ export function useActive(
171192
await new Promise((resolve) => setTimeout(resolve));
172193
setTargets();
173194

174-
const hashId = targets.value.find(({ id }) => id === location.hash.slice(1))?.id;
175-
if (hashId) {
176-
return (activeId.value = hashId);
195+
window.addEventListener('resize', onResize, { passive: true });
196+
197+
if (matchMedia.value) {
198+
const hashId = targets.value.find(({ id }) => id === location.hash.slice(1))?.id;
199+
if (hashId) {
200+
return (activeId.value = hashId);
201+
}
202+
203+
if (!jumpToEdges()) {
204+
onScrollDown();
205+
}
177206
}
207+
});
208+
209+
onBeforeUnmount(() => {
210+
window.removeEventListener('resize', onResize);
211+
});
212+
213+
const isClick = useScroll({
214+
isHTML,
215+
root,
216+
matchMedia,
217+
_setActive,
218+
});
178219

179-
if (!jumpToEdges()) {
220+
watch(isRef(userIds) || isReactive(userIds) ? userIds : () => null, setTargets, {
221+
flush: 'post',
222+
});
223+
224+
watch(matchMedia, (_matchMedia) => {
225+
if (_matchMedia) {
180226
onScrollDown();
227+
} else {
228+
activeId.value = '';
181229
}
182230
});
183231

184-
watch(
185-
isRef(userIds) || isReactive(userIds) ? userIds : () => null,
186-
() => {
187-
setTargets();
188-
},
189-
{ flush: 'post' }
190-
);
191-
192232
watch(activeId, (newId) => {
193233
if (replaceHash) {
194234
const start = jumpToFirst ? 0 : -1;
@@ -197,14 +237,6 @@ export function useActive(
197237
}
198238
});
199239

200-
const isClick = useListeners({
201-
isHTML,
202-
root,
203-
rootTop,
204-
_setActive,
205-
minWidth,
206-
});
207-
208240
return {
209241
isActive,
210242
setActive,
Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,18 @@
1-
import { watch, onMounted, ref, Ref, ComputedRef, onBeforeUnmount, computed, customRef } from 'vue';
2-
import { isSSR } from './utils';
1+
import { watch, onMounted, ref, Ref, ComputedRef, computed } from 'vue';
2+
import { isSSR, useRestrictedRef } from './utils';
33

44
type UseListenersOptions = {
55
isHTML: ComputedRef<boolean>;
66
root: Ref<HTMLElement | null>;
7-
rootTop: Ref<number>;
7+
matchMedia: Ref<boolean>;
88
_setActive: (prevY: number, isCancel?: { isCancel: boolean }) => void;
9-
minWidth: number;
109
};
1110

1211
const ONCE = { once: true };
1312

14-
export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: UseListenersOptions) {
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-
});
28-
29-
if (isSSR) {
30-
return isClick;
31-
}
32-
33-
const media = `(min-width: ${minWidth}px)`;
34-
const matchMedia = ref(window.matchMedia(media).matches);
35-
const isIdle = ref(false);
13+
export function useScroll({ isHTML, root, _setActive, matchMedia }: UseListenersOptions) {
14+
const isClick = useRestrictedRef(matchMedia, false);
15+
const isReady = ref(false);
3616
const clickY = computed(() => (isClick.value ? getNextY() : 0));
3717

3818
let prevY: number;
@@ -57,7 +37,7 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
5737
// When equal, wait for 20 frames to be sure is idle
5838
frameCount++;
5939
if (frameCount === 20) {
60-
isIdle.value = true;
40+
isReady.value = true;
6141
isClick.value = false;
6242
console.log('Scroll end.');
6343
cancelAnimationFrame(rafId);
@@ -69,11 +49,6 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
6949
rafId = requestAnimationFrame(scrollEnd);
7050
}
7151

72-
function onResize() {
73-
rootTop.value = isHTML.value ? 0 : root.value!.getBoundingClientRect().top;
74-
matchMedia.value = window.matchMedia(media).matches;
75-
}
76-
7752
function onScroll() {
7853
// Do not "update" results if scrolling from click
7954
if (!isClick.value) {
@@ -86,7 +61,7 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
8661
}
8762
}
8863

89-
// Restore main listener "updating" functionalities if scrolling again while scrolling from click...
64+
// Restore "updating" functionalities if scrolling again while scrolling from click...
9065
function reScroll() {
9166
isClick.value = false;
9267
}
@@ -108,35 +83,37 @@ export function useListeners({ isHTML, root, rootTop, _setActive, minWidth }: Us
10883
}
10984

11085
onMounted(() => {
111-
window.addEventListener('resize', onResize, { passive: true });
112-
if (matchMedia.value) {
86+
if (matchMedia.value && location.hash) {
11387
// Wait for any eventual scroll to hash triggered by browser to end
11488
setReady();
11589
} else {
116-
isIdle.value = true;
90+
isReady.value = true;
11791
}
11892
});
11993

120-
onBeforeUnmount(() => {
121-
window.removeEventListener('resize', onResize);
122-
});
123-
12494
watch(
125-
[isIdle, matchMedia, root],
126-
([_isIdle, _matchMedia, root], [], onCleanup) => {
127-
const rootEl = isHTML.value ? document : root;
95+
[isReady, matchMedia, root],
96+
([_isReady, _matchMedia, _root], [], onCleanup) => {
97+
if (isSSR) {
98+
return;
99+
}
100+
101+
const rootEl = isHTML.value ? document : _root;
102+
const isActive = rootEl && _isReady && _matchMedia;
128103

129-
if (_isIdle && rootEl && _matchMedia) {
104+
if (isActive) {
130105
console.log('Adding main listener...');
131106
rootEl.addEventListener('scroll', onScroll, {
132107
passive: true,
133108
});
109+
}
134110

135-
onCleanup(() => {
111+
onCleanup(() => {
112+
if (isActive) {
136113
console.log('Removing main listener...');
137114
rootEl.removeEventListener('scroll', onScroll);
138-
});
139-
}
115+
}
116+
});
140117
},
141118
{ immediate: true, flush: 'sync' }
142119
);

src/utils.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
1+
import { customRef, Ref } from 'vue';
2+
13
export const isSSR = typeof window === 'undefined';
24

35
export const FIXED_TO_TOP_OFFSET = 10;
46
export const FIXED_BOUNDARY_OFFSET = 5;
57

8+
export function useRestrictedRef<T>(matchMedia: Ref<boolean>, defaultValue: T): Ref<T> {
9+
const _customRef = customRef<T>((track, trigger) => {
10+
let value = defaultValue;
11+
return {
12+
get() {
13+
track();
14+
return value;
15+
},
16+
set(newValue) {
17+
value = matchMedia.value ? newValue : defaultValue;
18+
trigger();
19+
},
20+
};
21+
});
22+
23+
return _customRef;
24+
}
25+
626
export function getRects(targets: HTMLElement[], filter: 'IN' | 'OUT' | 'ALL', userOffset = 0) {
727
const extOffset = FIXED_BOUNDARY_OFFSET + userOffset;
828
const map = new Map<string, number>();
@@ -27,7 +47,7 @@ export function getRects(targets: HTMLElement[], filter: 'IN' | 'OUT' | 'ALL', u
2747
return map;
2848
}
2949

30-
export function getEdges(root = document.documentElement) {
50+
export function getEdges(root: HTMLElement) {
3151
const isTopReached = root.scrollTop <= FIXED_TO_TOP_OFFSET;
3252
const isBottomReached = Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) < 1;
3353
const isOverscrollTop = root.scrollTop < 0;

0 commit comments

Comments
 (0)