import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router'; import { useHistory } from '../lib/history'; import { disableDismissForRoutes } from '../lib/routeHelpers'; import { RoutePath } from '../lib/routes'; interface IKeyboardNavigationProps { children: React.ReactElement | Array; } // Listens for and handles keyboard shortcuts export default function KeyboardNavigation(props: IKeyboardNavigationProps) { const history = useHistory(); const [backAction, setBackActionImpl] = useState(); const location = useLocation(); // Since the backaction is now a function we need to make sure it's not called when setting the // state. const setBackAction = useCallback((backAction: BackActionFn | undefined) => { setBackActionImpl(() => backAction); }, []); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { const path = location.pathname as RoutePath; if (!disableDismissForRoutes.includes(path)) { if (event.shiftKey) { history.pop(true); } else { backAction?.(); } } } }, [history.pop, backAction, location.pathname], ); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); return {props.children}; } type BackActionFn = () => void; interface IBackActionContext { parentBackAction?: BackActionFn; registerBackAction: (backAction: BackActionFn) => void; removeBackAction: (backAction: BackActionFn) => void; } export const BackActionContext = React.createContext({ registerBackAction(_backAction) { throw new Error('Missing BackActionContext'); }, removeBackAction(_backAction) { throw new Error('Missing BackActionContext'); }, }); interface IBackActionProps { disabled?: boolean; action: BackActionFn; children: React.ReactNode; } // Component for registering back actions, e.g. navigate back or close modal. These are called // either by pressing the back button in the navigation bar or by pressing escape. export function BackAction(props: IBackActionProps) { const backActionContext = useContext(BackActionContext); const [childrenBackAction, setChildrenBackActionImpl] = useState(); // Since the backaction is now a function we need to make sure it's not called when setting the // state. const setChildrenBackAction = useCallback((backAction: BackActionFn | undefined) => { setChildrenBackActionImpl(() => backAction); }, []); // Each back action needs to be unique to make `removeBackAction` work. This is accomplished by // wrapping it in a callback. This was an issue since `history.pop`, which is commonly used as a // back action, is the same function for every component. const backAction = useCallback(() => { (childrenBackAction ?? props.action)(); }, [props.action, childrenBackAction]); // Every time the action or the disabled property changes the action needs to be reregistered. useEffect((): (() => void) | void => { if (!props.disabled && backAction) { backActionContext.registerBackAction(backAction); return () => backActionContext.removeBackAction(backAction); } }, [props.disabled, backAction]); // Every back action keeps track of the back actions in its subtree. This makes it possible to // always use the action furthest down in the tree. return ( {props.children} ); } interface IBackActionTracker { parentBackAction?: BackActionFn; registerBackAction: (backAction: BackActionFn | undefined) => void; children: React.ReactNode; } // This component keeps track of all registered back actions in it's subtree and reports one of them // to it's parent. function BackActionTracker(props: IBackActionTracker) { const [backActions, setBackActions] = useState>([]); const registerBackAction = useCallback((backAction: BackActionFn) => { setBackActions((backActions) => [...backActions, backAction]); }, []); const removeBackAction = useCallback((backAction: BackActionFn) => { setBackActions((backActions) => backActions.filter((action) => action !== backAction)); }, []); const backActionContext = useMemo( () => ({ parentBackAction: props.parentBackAction, registerBackAction, removeBackAction }), [backActions], ); useEffect(() => props.registerBackAction(backActions.at(0)), [backActions]); return ( {props.children} ); }