Measured every overlay in the tool before touching anything. For a dealer running this on a laptop, all six were broken in the same three ways. - focus never moved into the dialog: 0 of 5 measured - Tab walked straight out of the dialog into the page behind the scrim: 5 of 5 - Escape did nothing: only the rulings sheet handled it, 1 of 5 - the pot wizard — the dialog that pays people — had no role and no accessible name at all So opening the pot wizard left focus on the button behind it, tabbing wandered through a page nobody can see or click, and there was no way back out except the mouse. One implementation, not six The overlays are opened and closed by five different modules, which is why they each missed the same things. Rather than patch this into all of them, modal.js watches the `hidden` attribute they already toggle: focus moves to the first real control on open, Tab and Shift-Tab wrap inside the dialog, Escape closes it, and focus returns to whatever opened it. `aria-modal` is set while open and removed on close. An overlay added later gets all of it without anyone remembering to wire it up, which is the failure being fixed. Escape presses the dialog's own cancel button rather than just hiding the overlay, so the confirm dialog still resolves its promise — verified: Escape resolves it to false rather than leaving an await hanging forever. Shortcuts.overlayOpen() had the same shape of bug, listing the six overlay ids by hand. It asks the document now, so a sheet added later can't quietly start letting "f" fold the table behind it. Focus you can see Three focus rules existed for the whole stylesheet, so tabbing relied on the browser default ring, which on these dark custom-painted controls is close to invisible. `:focus-visible` keeps it to keyboard use so a dealer tapping Fold isn't left with a ring on it. That surfaced something small: `.holdemBtn` used `transition: all`, so the new outline faded in over 150ms and the ring lagged a step behind the Tab key. The transition names its properties now. Measured after: 16 of 16 tab stops show a ring immediately, and the press state from before still animates. Also - a mistyped negative stack read back as negative, and flowed into putIn() where Math.min(amount, -50) moves a negative amount and adds chips to the table. Clamped at zero. - accessible names for the chip colour, chip value and notes fields, which had none 11 new tests (99 total), covering negative stacks, negative and unreadable blinds, blinds nobody can cover, and a save that comes back corrupt, truncated, or from before a field existed. Playwright: all five overlays re-measured — focus moves in 5/5, Tab and Shift-Tab trapped 5/5, Escape closes 5/5, focus restored 5/5, role and aria-modal present 5/5. Shortcuts still fire when no dialog is open and are still blocked when one is. Touch guarantees from the earlier round unchanged: 0 of 49 controls on touch-action auto, 0 tap-highlighted. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
148 lines
5.7 KiB
JavaScript
148 lines
5.7 KiB
JavaScript
// 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;
|
|
}
|