Make the overlays usable from a keyboard #47

Merged
jheaps merged 1 commits from feature/casino-holdem-keyboard-focus into master 2026-09-01 09:30:08 -06:00
7 changed files with 363 additions and 24 deletions
+12 -11
View File
@@ -138,7 +138,7 @@
<details class="sidebarPanel"> <details class="sidebarPanel">
<summary>Table Notes</summary> <summary>Table Notes</summary>
<div class="panelBody"> <div class="panelBody">
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea> <textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div> </div>
</details> </details>
</aside> </aside>
@@ -198,8 +198,8 @@
<template id="chipLegendRowTemplate"> <template id="chipLegendRowTemplate">
<div class="chipLegendRow"> <div class="chipLegendRow">
<input type="color" class="chipColorInput" /> <input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" /> <input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button> <button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div> </div>
</template> </template>
@@ -253,7 +253,7 @@
</div> </div>
<div class="addChipsActions"> <div class="addChipsActions">
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button> <button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
<button id="addChipsCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button> <button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div> </div>
</div> </div>
</div> </div>
@@ -263,7 +263,7 @@
<div class="seatSheetGrip" aria-hidden="true"></div> <div class="seatSheetGrip" aria-hidden="true"></div>
<div class="rulingsHeader"> <div class="rulingsHeader">
<h2 id="rulingsTitle">Common rulings</h2> <h2 id="rulingsTitle">Common rulings</h2>
<button id="closeRulingsBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button> <button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div> </div>
<p class="rulingsIntro"> <p class="rulingsIntro">
@@ -314,7 +314,7 @@
<span id="seatMenuSubtitle"></span> <span id="seatMenuSubtitle"></span>
</div> </div>
<div id="seatMenuActions"></div> <div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button> <button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div> </div>
</div> </div>
@@ -324,7 +324,7 @@
<div class="cardPickerHeader"> <div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2> <h2 id="cardPickerTitle"></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button> <button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button> <button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div> </div>
<div id="cardPickerSlots"></div> <div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div> <div id="cardPickerGrid"></div>
@@ -340,17 +340,17 @@
<h2 id="confirmTitle"></h2> <h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p> <p id="confirmMessage"></p>
<div class="confirmActions"> <div class="confirmActions">
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button> <button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button> <button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div> </div>
</div> </div>
</div> </div>
<div id="potWizardOverlay" hidden> <div id="potWizardOverlay" hidden>
<div id="potWizardModal"> <div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader"> <div class="potWizardHeader">
<h2>Resolve the Pot</h2> <h2 id="potWizardTitle">Resolve the Pot</h2>
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button> <button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div> </div>
<div id="potWizardBody"></div> <div id="potWizardBody"></div>
</div> </div>
@@ -374,5 +374,6 @@
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
} }
+43 -1
View File
@@ -219,7 +219,11 @@ button,
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; /* Named rather than `all`, so the focus ring isn't animated. With `all` the
outline faded in over 150ms, which on keyboard navigation reads as the ring
lagging a step behind the Tab key. Nothing else here changes. */
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2690,3 +2694,41 @@ body.hasActionBar #toastHost {
display: none; display: none;
} }
} }
/* ---------- Keyboard focus ----------
Three focus rules existed for the whole tool, so tabbing through it relied on
whatever ring the browser draws by default — which on these dark, custom-painted
controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
.holdemBtn:focus-visible,
.linkBtn:focus-visible,
.seatMenuBtn:focus-visible,
.seatSheetClose:focus-visible,
.pickerCard:focus-visible,
.actionBtn:focus-visible,
.raiseQuickBtn:focus-visible,
.actionUndo:focus-visible,
.toastAction:focus-visible,
.toastClose:focus-visible,
.potWizardChoice:focus-visible,
.cardSlot:focus-visible,
.subPanel summary:focus-visible,
.seat:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid #7fc4ff;
outline-offset: 2px;
/* The ring has to sit above the neighbouring seat that overlaps this one. */
position: relative;
z-index: 3;
}
/* A sheet with nothing focusable in it takes focus itself so its title is read out.
That shouldn't paint a ring around the whole panel. */
[role="dialog"]:focus,
[role="alertdialog"]:focus {
outline: none;
}
@@ -39,11 +39,16 @@ const Betting = {
return this.round((Number(player.amountOverride) || 0) + this.committed(player)); return this.round((Number(player.amountOverride) || 0) + this.committed(player));
}, },
// Never below zero. A seat can't have negative chips in front of it, and a typo in
// the stack field used to come back as-is — which then flowed into putIn(), where
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
stackOf(state, player) { stackOf(state, player) {
const own = player.stack; const own = player.stack;
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own); if (own !== null && own !== undefined && String(own).trim() !== "") {
return Math.max(0, this.round(own));
}
const fallback = Number(state.defaultStack); const fallback = Number(state.defaultStack);
return Number.isNaN(fallback) ? 0 : this.round(fallback); return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
}, },
toCall(state, player) { toCall(state, player) {
@@ -1,6 +1,10 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load(); const state = HoldemState.load();
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
// `hidden` attribute they all already toggle.
Modal.mount();
// Undo works by swapping the whole table back to a snapshot. `state` is handed out // Undo works by swapping the whole table back to a snapshot. `state` is handed out
// by reference to every module at mount time, so it has to be refilled in place — // by reference to every module at mount time, so it has to be refilled in place —
// reassigning the variable would leave the renderer and the betting bar pointing at // reassigning the variable would leave the renderer and the betting bar pointing at
@@ -369,6 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (e.key === "Enter") AddChips.confirm(); if (e.key === "Enter") AddChips.confirm();
}); });
// Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay"); const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; }; const closeRulings = () => { rulingsOverlay.hidden = true; };
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; }); document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
@@ -376,9 +381,6 @@ document.addEventListener("DOMContentLoaded", () => {
rulingsOverlay.addEventListener("click", e => { rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings(); if (e.target === rulingsOverlay) closeRulings();
}); });
document.addEventListener("keydown", e => {
if (e.key === "Escape" && !rulingsOverlay.hidden) closeRulings();
});
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close()); document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => { document.getElementById("seatMenuOverlay").addEventListener("click", e => {
@@ -0,0 +1,147 @@
// 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;
}
@@ -32,14 +32,12 @@ const Shortcuts = {
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable; return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
}, },
// An overlay owns the keyboard while it's up: the confirm dialog already binds // An overlay owns the keyboard while it's up. Asking the document which overlays
// Enter and Escape, and the card picker and seat menu are modal. // are open, rather than listing them, means a sheet added later can't quietly
// start letting "f" fold the table behind it — the same reason Modal watches for
// them instead of being told about each one.
overlayOpen() { overlayOpen() {
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"] return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
.some(id => {
const el = document.getElementById(id);
return el && !el.hidden;
});
}, },
onKey(e) { onKey(e) {
+144
View File
@@ -0,0 +1,144 @@
// Values a dealer can actually produce with a typo, and the ones a corrupted save
// can produce on its own. None of these should be able to move chips.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("a stack can never go negative", () => {
test("a mistyped negative stack reads as empty, not as owing chips", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
});
test("a negative stack can't be bet with", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
const moved = Betting.putIn(state, 0, 25);
assert.equal(moved, 0, "nothing moves out of an empty stack");
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
});
test("a negative table default doesn't hand everyone a negative stack", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats.forEach(p => { p.stack = null; });
state.defaultStack = -100;
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
});
test("a negative stack doesn't make a player look like they can still bet", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
});
});
describe("blinds that can't be posted", () => {
test("negative blinds post nothing rather than paying players to sit down", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.smallBlind = -5;
state.bigBlind = -10;
const before = chipsOnTable(state);
Betting.beginHand(state);
state.seats.forEach(p => assert.equal(p.committed, 0));
assert.equal(chipsOnTable(state), before);
});
test("an unreadable blind doesn't wedge the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.bigBlind = NaN;
state.smallBlind = 1;
Betting.beginHand(state);
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
});
test("blinds nobody can cover put those players all-in and move on", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
state.smallBlind = 500;
state.bigBlind = 1000;
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
});
});
describe("a saved table that comes back damaged", () => {
test("unreadable storage falls back to a fresh table", () => {
// load() has to survive whatever is in localStorage, because the alternative
// is a dealer opening the tool mid-session to a blank screen.
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => "{not json at all",
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
assert.equal(state.handNumber, 1);
});
test("a save missing its seats is treated as no save at all", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
});
test("a save from before a field existed gets the field, not undefined", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => JSON.stringify({
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
handNumber: 4,
}),
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.equal(state.blindOverride, null);
assert.equal(state.street, "preflop");
state.seats.forEach(p => {
assert.equal(p.committed, 0);
assert.equal(p.hasActed, false);
assert.equal(p.buyIns, 0);
});
// And it has to be playable, not just parseable.
assert.doesNotThrow(() => Betting.beginHand(state));
});
test("storage that refuses to be written doesn't take the table down", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => null,
setItem: () => { throw new Error("QuotaExceededError"); },
removeItem: () => {},
};
const state = HoldemState.createDefault();
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
globalThis.localStorage = original;
});
});