Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/components/SidebarNav/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,114 @@ export const useNavAnimation = () => {

return { navAnimation, setNavAnimation };
};

/**
* Hook that detects clicks outside of a specified element.
*
* @param ref - Reference to the element to monitor for outside clicks
* @param onClickOutside - Callback function to execute when a click outside is detected
* @param enabled - Whether the hook should be active (default: true)
*/
export const useClickOutside = (
ref: React.RefObject<HTMLElement | null>,
onClickOutside: () => void,
enabled: boolean,
) => {
React.useEffect(() => {
if (!enabled) {
return;
}

const handleOutsideEvent = (event: MouseEvent | TouchEvent) => {
if (
ref?.current &&
!ref.current.contains(event.target as Node) &&
document.body.contains(event.target as Node)
) {
onClickOutside();
}
};

document.addEventListener("click", handleOutsideEvent);
document.addEventListener("touchend", handleOutsideEvent);

return () => {
document.removeEventListener("click", handleOutsideEvent);
document.removeEventListener("touchend", handleOutsideEvent);
};
}, [ref, onClickOutside, enabled]);
};

/**
* Hook that detects when the Escape key is pressed.
*
* @param onEscape - Callback function to execute when Escape is pressed
* @param enabled - Whether the hook should be active (default: true)
*/
export const useEscapeKey = (
onEscape: () => void,
enabled: boolean,
) => {
React.useEffect(() => {
if (!enabled) {
return;
}

const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape();
}
};

document.addEventListener("keydown", handleKeyPress);

return () => {
document.removeEventListener("keydown", handleKeyPress);
};
}, [onEscape, enabled]);
};

/**
* Hook that preserves scroll position of an element across re-renders.
*
* @param ref - Reference to the scrollable element
* @returns Function to update the scroll position
*/
export const useScrollRestoration = (
ref: React.RefObject<HTMLDivElement | null>,
) => {
const [scrollPosition, setScrollPosition] = React.useState(0);

// Restore scroll position after render
requestAnimationFrame(() => {
if (ref.current) {
ref.current.scrollTop = scrollPosition;
}
});

return setScrollPosition;
};

/**
* Hook that handles navigation collapse/expand with animation support.
*
* @param navIsCollapsed - Current collapsed state of navigation
* @param setNavIsCollapsed - Function to set collapsed state
* @param setNavAnimation - Function to set animation state
* @returns Function to handle setting the collapsed state with animation
*/
export const useNavCollapseHandler = (
navIsCollapsed: boolean,
setNavIsCollapsed: React.Dispatch<boolean>,
setNavAnimation: React.Dispatch<React.SetStateAction<"expanding" | "collapsing" | "idle" | "">>,
) => {
return React.useCallback(
(value: boolean) => {
if (value !== navIsCollapsed) {
setNavAnimation(value ? "collapsing" : "expanding");
}
setNavIsCollapsed(value);
},
[navIsCollapsed, setNavAnimation, setNavIsCollapsed],
);
};
82 changes: 29 additions & 53 deletions src/components/SidebarNav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ import {
collapsedWidth,
navStyles,
} from "./styles";
import { useNavAnimation, useSidebarNavProps } from "./hooks";
import {
useNavAnimation,
useSidebarNavProps,
useClickOutside,
useEscapeKey,
useScrollRestoration,
useNavCollapseHandler,
} from "./hooks";

type FunctionRender = (_: {
navIsCollapsed: boolean;
Expand Down Expand Up @@ -56,39 +63,18 @@ export const SidebarNavBase = ({
setNavIsCollapsed(isMobile);
}, [isMobile]); // eslint-disable-line react-hooks/exhaustive-deps

React.useEffect(() => {
if (!isMobile || navIsCollapsed) {
return;
}

const handleOutsideEvent = (event: any) => {
if (
isMobile &&
!navIsCollapsed &&
sidebarNavRef?.current &&
!sidebarNavRef.current.contains(event.target) &&
document.body.contains(event.target)
) {
setNavIsCollapsed(true);
}
};

const handleKeyPress = (event: KeyboardEvent) => {
if (isMobile && !navIsCollapsed && event.key === "Escape") {
setNavIsCollapsed(true);
}
};
// Close navigation when clicking outside or pressing Escape on mobile
const handleClose = React.useCallback(() => {
setNavIsCollapsed(true);
}, [setNavIsCollapsed]);

document.addEventListener("click", handleOutsideEvent);
document.addEventListener("touchend", handleOutsideEvent);
document.addEventListener("keydown", handleKeyPress);
useClickOutside(
sidebarNavRef as React.RefObject<HTMLElement | null>,
handleClose,
isMobile && !navIsCollapsed,
);

return () => {
document.removeEventListener("click", handleOutsideEvent);
document.removeEventListener("touchend", handleOutsideEvent);
document.removeEventListener("keydown", handleKeyPress);
};
}, [isMobile, navIsCollapsed, setNavIsCollapsed, sidebarNavRef]);
useEscapeKey(handleClose, isMobile && !navIsCollapsed);

const functionRenderArguments = {
navIsCollapsed,
Expand All @@ -103,13 +89,7 @@ export const SidebarNavBase = ({
}, [navAnimation]);

const navBodyRef = React.useRef<HTMLDivElement>(null);
const [scrollPosition, setScrollPosition] = React.useState(0);

requestAnimationFrame(() => {
if (navBodyRef.current) {
navBodyRef.current.scrollTop = scrollPosition;
}
});
const setScrollPosition = useScrollRestoration(navBodyRef);

return (
<FocusScope contain={isMobile && !navIsCollapsed}>
Expand Down Expand Up @@ -164,13 +144,11 @@ export const SidebarNav = styled(
const sidebarNavRef = React.useRef<HTMLElement>(null);
const { navAnimation, setNavAnimation } = useNavAnimation();

const handleSetNavIsCollapsed = (value: boolean) => {
if (value !== navIsCollapsed) {
setNavAnimation(value ? "collapsing" : "expanding");
}

setNavIsCollapsed(value);
};
const handleSetNavIsCollapsed = useNavCollapseHandler(
navIsCollapsed,
setNavIsCollapsed,
setNavAnimation,
);

return (
<nav
Expand Down Expand Up @@ -209,13 +187,11 @@ export const BodyPortalSidebarNav = styled(
const ref = React.useRef<HTMLElement>(document.createElement("NAV"));
const { navAnimation, setNavAnimation } = useNavAnimation();

const handleSetNavIsCollapsed = (value: boolean) => {
if (value !== navIsCollapsed) {
setNavAnimation(value ? "collapsing" : "expanding");
}

setNavIsCollapsed(value);
};
const handleSetNavIsCollapsed = useNavCollapseHandler(
navIsCollapsed,
setNavIsCollapsed,
setNavAnimation,
);

return (
<BodyPortal
Expand Down
Loading