import { type FC, useRef, useEffect, type ReactNode, type HTMLAttributes, } from 'react'; import { createPortal } from 'react-dom'; import cn from 'classnames'; import { frame } from 'timing-functions'; import Button from './button'; import CloseIcon from '../svg/times.svg'; import '../styles/components/sliding-panel.scss'; const focusable = 'button:not([disabled]), [href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'; export type LRBelowHeader = { /** * Where the sliding panel should appear */ position: 'left' | 'right'; /** * Horizontal position of the arrow if the panel appears below the page header. * Also works as a flag to display the arrow and display below the header */ arrowX?: number; }; export type TBSlidingPanel = { /** * Where the sliding panel should appear */ position: 'top' | 'bottom'; arrowX?: never; }; type SlidingPanelProps = { /** * What happens when close is triggered. Responsability of the user of the compoent */ onClose?: (reason: 'outside' | 'x-button' | 'navigation' | 'escape') => void; /** * Size of the panel once opened */ size?: 'small' | 'medium' | 'large' | 'full-screen'; /** * Title of the panel */ title?: ReactNode; /** * Pathname of current location. When this changes the panel is closed. */ pathname: string; } & (LRBelowHeader | TBSlidingPanel); const SlidingPanel: FC< SlidingPanelProps & Omit, 'title'> > = ({ children, onClose, position, size = 'medium', title, arrowX, className, pathname, ...props }) => { const nodeRef = useRef(null); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; // onMount/onUnmount useEffect(() => { // keep track of the currently active element (likely the button used) const previousActiveElement = document.activeElement as HTMLElement | null; // Mutation observer for when the content of the sliding panel changes // (because all the content might not be there immediatly) let mutationObs: MutationObserver | null = null; const focusTarget = nodeRef.current?.querySelector(focusable); if (focusTarget) { focusTarget.focus(); } else { // if there is no focusable element, wait for one to be rendered mutationObs = new MutationObserver(() => { // Get the first focusable element in the panel const focusTarget = nodeRef.current?.querySelector(focusable); if (focusTarget) { // If there is one, focus it and disconnect the observer focusTarget.focus(); mutationObs?.disconnect(); } }); if (nodeRef.current) { // Connect the observer to the panel mutationObs?.observe(nodeRef.current, { childList: true, subtree: true, }); } } // Clean up return () => { // Return focus to previously active element on unmount if still there if (previousActiveElement && document.contains(previousActiveElement)) { previousActiveElement?.focus(); } // Disconnect here too, just in case it didn't have a chance to do so mutationObs?.disconnect(); }; }, []); useEffect(() => { // Make sure to add it to the reference anyway every time to handle the // double renders in React in dev mode onCloseRef.current = onClose; return () => { // Lose the reference to the onClose function on unmount because we might // call it on the next frame but it will already be unmounted onCloseRef.current = undefined; }; }, [onClose]); // Handle closing the sliding panel when there's a click outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { // loop through all the currently opened panels for (const panel of document.querySelectorAll( '.sliding-panel' )) { // if the click event was within one, bail out of the whole function if (panel.contains(e.target as Node)) { return; } } // If none of the panels contains the target, close the panel. // Wait a frame in order to let other event listeners run before. frame().then(() => { onCloseRef.current?.('outside'); }); }; document.addEventListener('click', handleClickOutside, true); return () => { document.removeEventListener('click', handleClickOutside, true); }; }, []); // Handle closing the sliding panel when there's a path change const pathnameRef = useRef(pathname); useEffect(() => { // If the pathname changed if (pathnameRef.current !== pathname) { onCloseRef.current?.('navigation'); } }, [pathname]); useEffect(() => { const listener = (event: KeyboardEvent) => { if (event.key === 'Escape') { onCloseRef.current?.('escape'); } }; document.addEventListener('keydown', listener, { passive: true }); return () => { document.removeEventListener('keydown', listener); }; }, []); return createPortal( , document.body ); }; export default SlidingPanel;