import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useHistory } from '../lib/history'; import { useEffectEvent } from '../lib/utility-hooks'; interface IKeyboardNavigationProps { children: React.ReactElement | Array; } // Listens for and handles keyboard shortcuts export default function KeyboardNavigation(props: IKeyboardNavigationProps) { const { pop } = useHistory(); const [backAction, setBackActionImpl] = useState(); // 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') { if (event.shiftKey && window.env.development) { pop(true); } else { backAction?.(); } } }, [pop, backAction], ); 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 { registerBackAction, removeBackAction } = 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) { registerBackAction(backAction); return () => removeBackAction(backAction); } }, [props.disabled, backAction, registerBackAction, removeBackAction]); // 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 }), [props.parentBackAction, registerBackAction, removeBackAction], ); const registerBackActionEvent = useEffectEvent((backActions: Array) => { props.registerBackAction(backActions.at(0)); }); // These lint rules are disabled for now because the react plugin for eslint does // not understand that useEffectEvent should not be added to the dependency array. // Enable these rules again when eslint can lint useEffectEvent properly. // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => registerBackActionEvent(backActions), [backActions]); return ( {props.children} ); }