/** * A masthead control that opens a panel under itself. * * There are two of them now — the account menu and the theme menu — and they * close the same three ways: the button again, a click anywhere outside, and * Escape. Written once here so that a fix to one is a fix to both. * * The document listeners exist only while a panel is open, so a page sitting * with both menus closed has none of them attached. */ /** * Wire a button to the panel it opens. * * `control` is the element that contains both, and is what an outside click is * measured against — the button's own click reaches the document listener too, * after its handler has already toggled the panel open, and the containment * check is what stops that closing it again. * * A link inside the panel closes it. On the app page a menu link is a hash * change, so nothing reloads and nothing else would ever put it away. * * Returns the open/close switch, for a panel item that is a button rather * than a link — the account menu's "Report an issue" opens a dialog, and the * menu has to put itself away first or it sits open behind the backdrop. */ export function attachMenu( control: HTMLElement, button: HTMLElement, panel: HTMLElement, ): (open: boolean) => void { const setOpen = (open: boolean): void => { panel.hidden = !open; button.setAttribute("aria-expanded", String(open)); if (open) { document.addEventListener("click", onDocumentClick); document.addEventListener("keydown", onKeyDown); } else { document.removeEventListener("click", onDocumentClick); document.removeEventListener("keydown", onKeyDown); } }; function onDocumentClick(event: MouseEvent): void { if (!control.contains(event.target as Node)) setOpen(false); } function onKeyDown(event: KeyboardEvent): void { if (event.key !== "Escape") return; setOpen(false); button.focus(); } button.addEventListener("click", () => setOpen(panel.hidden)); for (const item of panel.querySelectorAll("a")) { item.addEventListener("click", () => setOpen(false)); } return setOpen; }