// Putting focus back where it was. // // Everything in this app that opens over something else — the ⊕ menu, the account popover, a compose // card, the space picker — is opened from a control that stays on the page underneath it. Closing one // without saying where focus goes drops it on ``, which for a keyboard or a screen reader is the // app quietly losing its place: the next Tab starts from the top of the document, and the row that was // being read is gone. // // So: capture on the way in, restore on the way out — but never *take* focus from something that has // claimed it since. Closing a menu by choosing the item that opens a compose card is the ordinary case // of that: the card puts the caret in its first field, and the menu must not pull it back out. // // It is deliberately not a focus trap. None of these are modal in the sense that would justify one — // the picker's scrim is the closest, and even there reading the page behind it is legitimate. /** Something focus can be handed to. Structural, so a test can pass a plain object. */ export interface Focusable { focus(): void } /** Nothing in particular has focus, so putting it somewhere deliberate takes it from nobody. */ export function focusIsLoose(): boolean { if (typeof document === 'undefined') return false const active = document.activeElement return active === null || active === document.body } /** * Focus has nowhere to be: either nothing holds it, or what holds it is inside `closing` — a menu item * in the menu that is being dismissed, which the browser is about to blur anyway because a `hidden` * subtree cannot hold focus. Asked *before* that blur happens, because it is not synchronous. */ export const focusStranded = (closing: { contains(node: Node | null): boolean } | null | undefined): boolean => focusIsLoose() || (typeof document !== 'undefined' && (closing?.contains(document.activeElement) ?? false)) /** * Remember what has focus now; the returned function gives it back. * * It answers whether focus ended up somewhere — false means the element it was holding is gone, and * the caller should put focus on whatever control the closed surface belonged to. `stranded` says * whether it is this caller's business at all: by default it declines to take focus from something * that has claimed it since. */ export function keepFocus(): (stranded?: boolean) => boolean { const previous = typeof document === 'undefined' ? null : document.activeElement return (stranded = focusIsLoose()) => { if (!stranded) return true if (!(previous instanceof HTMLElement) || !previous.isConnected) return false // Focus was on nothing when this was captured — the ⊕ menu opened with ⌘N is the ordinary case — // so there is nothing to go back to, and the caller's own control is the better answer. if (previous === document.body) return false // An element inside a `hidden` subtree — a chosen item in a menu that has just closed — is still // in the document and cannot take focus, so it is not a place to go back to. if (previous.checkVisibility?.() === false) return false previous.focus() return true } }