import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router'; import { useHistory } from '../lib/history'; import { disableDismissForRoutes, 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, setBackAction] = useState(); const location = useLocation(); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { const path = location.pathname as RoutePath; if (!disableDismissForRoutes.includes(path)) { if (event.shiftKey) { history.dismiss(true); } else { backAction?.action(); } } } }, [history.dismiss, backAction, location.pathname], ); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); return {props.children}; } type BackActionIcon = 'back' | 'close'; type BackActionFn = () => void; interface IBackActionConfiguration { icon: BackActionIcon; action: BackActionFn; } interface IBackActionContext { parentBackAction?: IBackActionConfiguration; registerBackAction: (backAction: IBackActionConfiguration) => void; removeBackAction: (backAction: IBackActionConfiguration) => void; } export const BackActionContext = React.createContext({ registerBackAction(_backAction) { throw new Error('Missing BackActionContext'); }, removeBackAction(_backAction) { throw new Error('Missing BackActionContext'); }, }); interface IBackActionProps { disabled?: boolean; icon?: BackActionIcon; 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, setChildrenBackAction] = useState(); const parentBackAction = useMemo( () => ({ icon: props.icon ?? 'back', action: props.action }), [props.icon, props.action], ); const backActionConfiguration = childrenBackAction ?? parentBackAction; // Every time the action or the disabled property changes the action needs to be reregistered. useEffect((): (() => void) | void => { if (!props.disabled && backActionConfiguration) { backActionContext.registerBackAction(backActionConfiguration); return () => backActionContext.removeBackAction(backActionConfiguration); } }, [props.disabled, backActionConfiguration]); // 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?: IBackActionConfiguration; registerBackAction: (backAction: IBackActionConfiguration | 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: IBackActionConfiguration) => { setBackActions((backActions) => [...backActions, backAction]); }, []); const removeBackAction = useCallback((backAction: IBackActionConfiguration) => { 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} ); }