-
Notifications
You must be signed in to change notification settings - Fork 80
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
542 additions
and
133 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export const debounce = (callback: Function, wait: number) => { | ||
let timeoutId: NodeJS.Timeout; | ||
return (...args: unknown[]) => { | ||
clearTimeout(timeoutId); | ||
timeoutId = setTimeout(() => { | ||
callback.apply(null, args); | ||
}, wait); | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export type { ChangesType, RegisterOptions, useNavScrollArgs, useNavScrollResult } from './types'; | ||
|
||
export { useNavScroll } from './useNavScroll'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { HTMLAttributes, RefObject } from 'react'; | ||
|
||
/** | ||
* The returned object from the `onChange` passed function, with the current state of | ||
* `added` and `removed` element ids. | ||
*/ | ||
export type ChangesType = { | ||
added: string | null; | ||
removed: string | null; | ||
}; | ||
|
||
/** | ||
* The react-use-navscroll configuration object | ||
*/ | ||
export type useNavScrollArgs = { | ||
/** | ||
* Function called every time an element becomes active. | ||
* The changes object returned contains the "added" and "removed" id. | ||
* */ | ||
onChange?: (changes: ChangesType) => void; | ||
/** | ||
* Pass an element as root to track a specific container, smaller than a viewport. Default is window (the whole viewport). | ||
* */ | ||
root?: Element; | ||
/** | ||
* Moves the detection line by the amount of this offset, expressed in percentage. By default the value is 50 (center). | ||
* */ | ||
offset?: number; | ||
/** | ||
* Declare if the detection should work vertically or horizontally. By default false (vertical) | ||
*/ | ||
isHorizontal?: boolean; | ||
}; | ||
|
||
/** | ||
* The options object passed to the `register` function. | ||
*/ | ||
export type RegisterOptions = { | ||
/** | ||
* Pass the string id of the parent element | ||
*/ | ||
parent?: string; | ||
/** | ||
* If the tracked element has already a reference, you can pass it and will be reused | ||
*/ | ||
ref?: RefObject<Element>; | ||
}; | ||
|
||
/** | ||
* The attributes object to assign to the element to assign | ||
*/ | ||
export type RegisteredAttributes<T extends Element> = { | ||
id: HTMLAttributes<T>['id']; | ||
ref: RefObject<T> | null; | ||
}; | ||
|
||
/** | ||
* The object returned by the hook. | ||
*/ | ||
export type useNavScrollResult = { | ||
/** | ||
* The function used to register the component into the tracking system. | ||
* It returns the id already passed and the reference object. | ||
* Note that only the reference value will be `null` in a SSR context. | ||
*/ | ||
register: <T extends Element>(id: string, options?: RegisterOptions) => RegisteredAttributes<T>; | ||
/** | ||
* Removes the given id from the tracking system. | ||
*/ | ||
unregister: (idToUnregister: string) => void; | ||
/** | ||
* A list of active ids (the full hierarchy). | ||
*/ | ||
activeIds: string[]; | ||
/** | ||
* A convenience function to quickly check the active state for the given id | ||
*/ | ||
isActive: (id: string) => boolean; | ||
/** | ||
* A function to retrieve the reference of the current active element (only the last element, not the elements hierarchy). | ||
*/ | ||
getActiveRef: () => RefObject<Element> | null; | ||
}; | ||
|
||
// @private | ||
export type TrackedElement = { | ||
id: string; | ||
} & Required<Pick<RegisterOptions, 'ref'>> & | ||
Pick<RegisterOptions, 'parent'>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||
import { debounce } from './debounce'; | ||
import type { TrackedElement, useNavScrollArgs, useNavScrollResult } from './types'; | ||
import { useSizeDetector } from './useSizeDetector'; | ||
|
||
const hasWindow = typeof window !== 'undefined'; | ||
const REGISTER_DELAY = 50; | ||
|
||
function resolveHierarchyIds(id: string, lookup: Record<string, string | undefined>) { | ||
const newActiveIds = [id]; | ||
let lastId: string | undefined = newActiveIds[0]; | ||
while (lastId != null && lookup[lastId] != null) { | ||
newActiveIds.push(lookup[lastId] as string); | ||
lastId = lookup[lastId]; | ||
} | ||
// return a list from parent to current child | ||
return newActiveIds.reverse(); | ||
} | ||
/** | ||
* This is the main hook: use it in a react function component to track | ||
* the state of the passed ids. The function accepts an initial configuration | ||
* of type `useNavScrollArgs` to customize the behaviour. | ||
*/ | ||
export function useNavScroll(args: useNavScrollArgs = {}): useNavScrollResult { | ||
const { onChange, root, offset = 50, isHorizontal = false } = args; | ||
const els = useRef<TrackedElement[]>([]); | ||
const [counter, setCounter] = useState(0); | ||
const [forceRecompute, setForceRecompute] = useState(false); | ||
const [activeId, updateActiveId] = useState<string | null>(null); | ||
|
||
const { targetSize, useViewport } = useSizeDetector({ | ||
root, | ||
isHorizontal, | ||
onChange, | ||
activeId, | ||
setForceRecompute, | ||
updateActiveId, | ||
hasWindow | ||
}); | ||
|
||
const observerMargin = Math.floor((targetSize * offset) / 100) || 1; | ||
const observerOptions = useMemo(() => { | ||
const topMargin = observerMargin % 2 === 1 ? observerMargin - 1 : observerMargin; | ||
const bottomMargin = targetSize - observerMargin; | ||
return { | ||
root: useViewport ? null : root, | ||
rootMargin: isHorizontal | ||
? `0px ${-topMargin}px 0px ${-bottomMargin}px` | ||
: `${-topMargin}px 0px ${-bottomMargin}px 0px` | ||
}; | ||
}, [root, targetSize, observerMargin, isHorizontal, useViewport]); | ||
|
||
const elsLookup = useMemo(() => { | ||
const lookup: Record<string, string | undefined> = {}; | ||
for (const { id, parent } of els.current) { | ||
lookup[id] = parent; | ||
} | ||
return lookup; | ||
}, []); | ||
const activeIds = useMemo(() => (activeId ? resolveHierarchyIds(activeId, elsLookup) : []), [activeId, elsLookup]); | ||
|
||
const activeLookups = useMemo(() => new Set(activeIds), [activeIds]); | ||
useEffect(() => { | ||
if (!hasWindow) { | ||
return; | ||
} | ||
const handleIntersection: IntersectionObserverCallback = (entries) => { | ||
let intersectionId = null; | ||
let topMin = Infinity; | ||
entries.forEach((entry) => { | ||
if (entry.isIntersecting) { | ||
if (topMin > entry.boundingClientRect.top) { | ||
topMin = entry.boundingClientRect.top; | ||
intersectionId = entry.target.id; | ||
} | ||
} | ||
}); | ||
if (intersectionId != null) { | ||
updateActiveId(intersectionId); | ||
if (onChange) { | ||
const diffIds = { | ||
added: intersectionId, | ||
removed: activeId | ||
}; | ||
onChange(diffIds); | ||
} | ||
} | ||
}; | ||
|
||
const observer = new IntersectionObserver(handleIntersection, observerOptions); | ||
|
||
els.current.forEach(({ ref }) => { | ||
if (ref && ref.current) { | ||
observer.observe(ref.current); | ||
} | ||
}); | ||
|
||
if (forceRecompute) { | ||
handleIntersection(observer.takeRecords(), observer); | ||
setForceRecompute(false); | ||
} | ||
return () => { | ||
observer.disconnect(); | ||
}; | ||
}, [ | ||
activeIds, | ||
updateActiveId, | ||
els, | ||
elsLookup, | ||
onChange, | ||
activeLookups, | ||
activeId, | ||
observerOptions, | ||
isHorizontal, | ||
root, | ||
forceRecompute | ||
]); | ||
|
||
const refresh = useCallback( | ||
debounce(() => { | ||
setCounter(counter + 1); | ||
}, REGISTER_DELAY), | ||
[counter] | ||
); | ||
|
||
const register = useCallback( | ||
(id, options = {}) => { | ||
if (!hasWindow) { | ||
return { id, ref: null }; | ||
} | ||
const alreadyRegistered = id in elsLookup; | ||
const entry = alreadyRegistered ? els.current.find(({ id: existingId }) => existingId === id) : options; | ||
const ref = (entry && entry.ref) || createRef(); | ||
|
||
if (!alreadyRegistered) { | ||
els.current = [...els.current, { id, ref, parent: options.parent }]; | ||
refresh(); | ||
} | ||
return { id, ref }; | ||
}, | ||
[elsLookup, refresh] | ||
); | ||
|
||
const unregister = useCallback((idToUnregister: string) => { | ||
els.current = els.current.filter(({ id }) => id !== idToUnregister); | ||
}, []); | ||
|
||
const isActive = useCallback((id: string) => activeLookups.has(id), [activeLookups]); | ||
|
||
const getActiveRef = useCallback(() => { | ||
const entry = els.current.find(({ id }) => id === activeId); | ||
return entry ? entry.ref : null; | ||
}, [activeId]); | ||
|
||
return { | ||
register, | ||
unregister, | ||
activeIds, | ||
isActive, | ||
getActiveRef | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
/* eslint-disable no-restricted-globals */ | ||
import { useCallback, useEffect, useState } from 'react'; | ||
import { debounce } from './debounce'; | ||
import { useNavScrollArgs } from './types'; | ||
|
||
const DEFAULT_DELAY = 150; | ||
|
||
export type useSizeDetectorArgs = Omit<useNavScrollArgs, 'offset'> & { | ||
activeId: string | null; | ||
hasWindow: boolean; | ||
setForceRecompute: (force: boolean) => void; | ||
updateActiveId: (id: string | null) => void; | ||
}; | ||
|
||
export const useSizeDetector = ({ | ||
root, | ||
isHorizontal, | ||
activeId, | ||
onChange, | ||
setForceRecompute, | ||
updateActiveId, | ||
hasWindow | ||
}: useSizeDetectorArgs) => { | ||
const [targetSize, setTargetSize] = useState<number>(1); | ||
|
||
const useViewport = | ||
root == null || | ||
(hasWindow && (isHorizontal ? window.innerWidth < root.clientWidth : window.innerHeight < root.clientHeight)); | ||
|
||
const scrollEnd = useCallback( | ||
debounce(() => { | ||
setForceRecompute(true); | ||
}, DEFAULT_DELAY), | ||
[setForceRecompute] | ||
); | ||
|
||
useEffect(() => { | ||
if (!hasWindow) { | ||
return; | ||
} | ||
let observer: IntersectionObserver | null = null; | ||
|
||
const resizeWindowHandler = () => { | ||
setTimeout(() => { | ||
setTargetSize(isHorizontal ? window.innerWidth : window.innerHeight); | ||
}, DEFAULT_DELAY); | ||
}; | ||
|
||
const resizeElementHandler: IntersectionObserverCallback = (entries) => { | ||
const [entry] = entries; | ||
if (!useViewport) { | ||
setTargetSize(isHorizontal ? entry.boundingClientRect.width : entry.boundingClientRect.height); | ||
} | ||
|
||
if (entry.intersectionRatio === 0) { | ||
if (activeId != null) { | ||
updateActiveId(null); | ||
if (onChange) { | ||
onChange({ | ||
added: null, | ||
removed: activeId | ||
}); | ||
} | ||
} | ||
} | ||
}; | ||
|
||
addEventListener('scroll', scrollEnd); | ||
if (useViewport) { | ||
setTargetSize(isHorizontal ? window.innerWidth : window.innerHeight); | ||
addEventListener('resize', resizeWindowHandler); | ||
} | ||
if (root) { | ||
observer = new IntersectionObserver(resizeElementHandler); | ||
observer.observe(root); | ||
} | ||
return () => { | ||
if (observer) { | ||
observer.disconnect(); | ||
} | ||
removeEventListener('resize', resizeWindowHandler); | ||
removeEventListener('scroll', scrollEnd); | ||
}; | ||
}, [root, isHorizontal, activeId, onChange, useViewport, scrollEnd, hasWindow, updateActiveId]); | ||
return { targetSize, useViewport }; | ||
}; |
Oops, something went wrong.