// Keyboard and screen-reader behaviour for every overlay in the tool, in one place. // // There are six of them — seat menu, card picker, add chips, rulings, pot wizard, // confirm — opened and closed by five different modules, and each one was missing // the same three things: focus never moved into the dialog, Tab walked straight out // of it into the page behind the scrim, and Escape did nothing (only the rulings // sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab, // 1 of 5 closed on Escape. // // Rather than patch that into five modules, this watches the `hidden` attribute every // overlay already toggles. Nothing else had to change, and an overlay added later // gets the same behaviour without anyone remembering to wire it up — which is the // failure this is fixing in the first place. const Modal = { // `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay // must not be a Tab stop. FOCUSABLE: [ "a[href]", "button:not([disabled])", 'input:not([disabled]):not([type="hidden"])', "select:not([disabled])", "textarea:not([disabled])", '[tabindex]:not([tabindex="-1"])', ].join(","), open: [], mount() { document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay)); // Capture phase: the dialog gets first refusal on Escape and Tab, before any // table-level shortcut sees them. document.addEventListener("keydown", e => this.onKeyDown(e), true); }, watch(overlay) { const sync = () => { const isOpen = !overlay.hidden; const known = this.open.some(entry => entry.overlay === overlay); if (isOpen && !known) this.onOpened(overlay); else if (!isOpen && known) this.onClosed(overlay); }; new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] }); sync(); }, sheetOf(overlay) { return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay; }, focusableIn(overlay) { return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => { if (el.closest("[hidden]")) return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }); }, onOpened(overlay) { const sheet = this.sheetOf(overlay); // aria-modal is only true while the thing is actually open, so it's set here // rather than baked into the markup. if (sheet) sheet.setAttribute("aria-modal", "true"); this.open.push({ overlay, returnFocus: document.activeElement }); // Focus the first real control. A dialog that leaves focus on the button // behind it reads, to a screen reader, as though nothing happened at all. // Content-only sheets get focus on the sheet so the title is announced. const focusable = this.focusableIn(overlay); const target = focusable[0]; if (target) { target.focus({ preventScroll: true }); } else if (sheet) { if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1"); sheet.focus({ preventScroll: true }); } }, onClosed(overlay) { const index = this.open.findIndex(entry => entry.overlay === overlay); if (index === -1) return; const [entry] = this.open.splice(index, 1); const sheet = this.sheetOf(overlay); if (sheet) sheet.removeAttribute("aria-modal"); // Put the dealer back where they were. Without this, closing the pot wizard // drops focus to the top of the document and the next Tab starts from the // page header. const back = entry.returnFocus; if (back && back.isConnected && typeof back.focus === "function") { back.focus({ preventScroll: true }); } }, topmost() { return this.open.length ? this.open[this.open.length - 1] : null; }, // Escape presses the dialog's own cancel/close button rather than just hiding the // overlay, so whatever that button does — resolving the confirm promise, clearing // the add-chips draft — still happens. Only if there isn't one does it hide the // overlay directly. dismiss(overlay) { const closer = overlay.querySelector("[data-modal-close]"); if (closer) closer.click(); else overlay.hidden = true; }, onKeyDown(event) { const top = this.topmost(); if (!top) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); this.dismiss(top.overlay); return; } if (event.key !== "Tab") return; // Keep Tab inside the dialog. Everything behind the scrim is unreachable by // mouse, so letting the keyboard wander back there is a trap with no way out. const focusable = this.focusableIn(top.overlay); if (focusable.length === 0) { event.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; if (event.shiftKey && (active === first || !top.overlay.contains(active))) { event.preventDefault(); last.focus({ preventScroll: true }); } else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) { event.preventDefault(); first.focus({ preventScroll: true }); } }, }; if (typeof module !== "undefined" && module.exports) { module.exports = Modal; }