Skip to content

Commit

Permalink
feat: integrate nav scroll
Browse files Browse the repository at this point in the history
  • Loading branch information
Virtute90 committed Mar 11, 2024
1 parent 38de4b7 commit 5b773be
Show file tree
Hide file tree
Showing 11 changed files with 542 additions and 133 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"@svgr/webpack": "^6.4.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^11.2.6",
"@testing-library/react-hooks": "^8.0.1",
"@types/is-number": "^7.0.3",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
Expand Down Expand Up @@ -146,7 +147,6 @@
"react-stickup": "^1.12.1",
"react-toastify": "^7.0.4",
"react-transition-group": "^4.4.5",
"react-use-navscroll": "0.2.0",
"reactstrap": "9.2.2",
"webfontloader": "^1.6.28"
},
Expand Down
9 changes: 9 additions & 0 deletions src/NavScroll/debounce.ts
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);
};
};
3 changes: 3 additions & 0 deletions src/NavScroll/index.tsx
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';
89 changes: 89 additions & 0 deletions src/NavScroll/types.ts
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'>;
162 changes: 162 additions & 0 deletions src/NavScroll/useNavScroll.ts
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
};
}
86 changes: 86 additions & 0 deletions src/NavScroll/useSizeDetector.ts
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 };
};
Loading

0 comments on commit 5b773be

Please sign in to comment.