import React, { useCallback, useState, useEffect } from "react"; import { WindowId, AppState, Dispatch } from "../types"; import * as Actions from "../actionCreators"; import * as Selectors from "../selectors"; import { connect } from "react-redux"; interface DispatchProps { setFocus(windowId: WindowId | null): void; } interface StateProps { focusedWindowId: WindowId | null; } interface OwnProps { onKeyDown?(e: KeyboardEvent): void; windowId: WindowId; children: React.ReactNode; } type Props = StateProps & DispatchProps & OwnProps; function FocusTarget(props: Props) { const { onKeyDown, focusedWindowId, windowId, setFocus, children } = props; const focusHandler = useCallback(() => { if (windowId !== focusedWindowId) { setFocus(windowId); } }, [windowId, focusedWindowId, setFocus]); const [ref, setRef] = useState(null); useEffect(() => { if (ref == null || onKeyDown == null) { return; } ref.addEventListener("keydown", onKeyDown); return () => ref.removeEventListener("keydown", onKeyDown); }, [onKeyDown, windowId, focusedWindowId, ref]); // It's possible for a child component to gain focus and then become // unmounted. In that case, the browser will return focus to the ``. // In the following hook, use a `MutationObserver` to watch for that behavior // and refocus the containing FocusTarget when it happens. // // I tried a number of other approaches using `focus/blur/focusin/focusout` on // various DOM nodes, and was unable to find a solution which would trigger in // this senario in Firefox. Therefore we use this `MutationObserver` approach. useEffect(() => { // Only create the `MutationObserver` within the currently focused target. if (ref == null || windowId !== focusedWindowId) { return; } const observer = new MutationObserver(mutations => { // In the common case we won't have focused the body, so we can do this // inexpensive check first to avoid calling the more expensive `O(n)` // check of the individual mutations. if (document.activeElement !== document.body) { return; } if (mutations.some(mutation => mutation.removedNodes.length > 0)) { ref.focus(); } }); observer.observe(ref, { subtree: true, attributes: false, childList: true, }); return () => observer.disconnect(); }, [windowId, focusedWindowId, ref]); return (
{children}
); } function mapStateToProps(state: AppState): StateProps { return { focusedWindowId: Selectors.getFocusedWindow(state), }; } function mapDispatchToProps(dispatch: Dispatch): DispatchProps { return { setFocus: windowId => dispatch(Actions.setFocusedWindow(windowId)), }; } export default connect(mapStateToProps, mapDispatchToProps)(FocusTarget);