Let the keyboard into the sheets, and stop dropping it on the floor

Modal answers one question for all six overlays — where focus goes and how
Tab is held — and this round it was measured as wrong in three directions.

Its list of Tab stops could not see a <summary>. The rulings sheet is twelve
of them and one Done button, so the trap decided Done was both ends of the
ring: eighty Tab presses, one element ever reached, twelve rulings that could
not be opened without a mouse. Adding `details > summary` gives the sheet its
thirteen stops back. `:not([tabindex="-1"])` moves onto every line while it is
open, because a control taken out of the Tab order still matches `button`.

"The first control" was the card picker's Clear: the sheet a dealer opens to
write a card down opened on the button that rubs one out. A sheet names its
own landing spot now, and on five of six it is the heading — which says which
seat, which card, which pot. The confirm dialog keeps a control, because a
short question with one irreversible answer should land on the answer that
leaves the dealer no worse off.

And a sheet that redraws itself was deleting the control that held focus.
Choosing a buy-in, picking a card, switching slots, confirming a side pot —
all four left focus on the document body. Rebuilt controls carry a name now
and are handed back to whoever had them; where there is no coming back, the
caller says where to go instead.

The card grid was fifty-two Tab stops of the fifty-six in that sheet. It is
one now, with the arrows moving inside it, stepping over cards already dealt
and stopping at the edges rather than wrapping onto a different rank.

1136 tests, 418 audits clean.
This commit is contained in:
2026-09-11 18:49:42 -06:00
parent 382238e708
commit 49c2d745eb
9 changed files with 662 additions and 52 deletions
+27 -11
View File
@@ -480,7 +480,7 @@
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle"> <div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div> <div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader"> <div class="addChipsHeader">
<h2 id="addChipsTitle"></h2> <h2 id="addChipsTitle" tabindex="-1" data-modal-focus></h2>
<span id="addChipsSubtitle"></span> <span id="addChipsSubtitle"></span>
</div> </div>
<div id="addChipsQuick"></div> <div id="addChipsQuick"></div>
@@ -499,7 +499,7 @@
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle"> <div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
<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" tabindex="-1" data-modal-focus>Common rulings</h2>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button> <button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div> </div>
@@ -696,15 +696,20 @@
earns its place twice over here: this sheet is the one that can outgrow the earns its place twice over here: this sheet is the one that can outgrow the
screen, and with the header stuck to the top the way out never scrolls away. screen, and with the header stuck to the top the way out never scrolls away.
Third job, the quiet one: Modal focuses a dialog's first control, and the Third job, the quiet one: Escape finds this close button rather than the one
first control used to be Fold. The APG says to keep initial focus off the at the foot, so Escape closes the sheet while Back steps back inside it.
destructive choice, and a close button at the top is what puts it there
without a special case. Escape finds this one rather than the button at the It used to carry a fourth. Modal focused a dialog's first control and the
foot, so Escape closes the sheet while Back steps back inside it. *@ first control here was Fold, so putting a close button above the list was
also what moved the landing spot off the destructive choice. That was the
right half of the answer — Roselli is clear that a close button is a landing
spot for a sentence, not for a sheet long enough to scroll, where a stray
Space dismisses the thing the dealer just opened. The heading below names
the spot outright now, and it says which seat. *@
<div class="seatMenuHeader"> <div class="seatMenuHeader">
<div class="seatSheetGrip" aria-hidden="true"></div> <div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeading"> <div class="seatMenuHeading">
<h2 id="seatMenuTitle"></h2> <h2 id="seatMenuTitle" tabindex="-1" data-modal-focus></h2>
<span id="seatMenuSubtitle"></span> <span id="seatMenuSubtitle"></span>
</div> </div>
<button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button> <button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button>
@@ -718,7 +723,12 @@
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle"> <div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
<div class="seatSheetGrip" aria-hidden="true"></div> <div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader"> <div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2> @* Not the first control any more. Modal used to land on whatever came
first in here, and what came first in here was Clear: the sheet a
dealer opens to write a card down opened on the button that rubs one
out. The heading says which seat and which card, which is the thing
worth hearing before any of the fifty-two. *@
<h2 id="cardPickerTitle" tabindex="-1" data-modal-focus></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button> <button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button> <button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div> </div>
@@ -745,8 +755,14 @@
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1"> <div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2> <h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p> <p id="confirmMessage"></p>
@* The one sheet that lands on a control rather than its heading, and the one
where the APG asks for it by name: this is a question with two answers and
one of them cannot be taken back, so the dealer starts on the answer that
leaves them no worse off. It is short enough to be read at a glance, which is
the case where landing on a control is the right thing. Said out loud here
rather than left to depend on which button happens to be written first. *@
<div class="confirmActions"> <div class="confirmActions">
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button> <button id="confirmCancelBtn" data-modal-close data-modal-focus 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>
@@ -755,7 +771,7 @@
<div id="potWizardOverlay" hidden> <div id="potWizardOverlay" hidden>
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle"> <div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader"> <div class="potWizardHeader">
<h2 id="potWizardTitle">Resolve the Pot</h2> <h2 id="potWizardTitle" tabindex="-1" data-modal-focus>Resolve the Pot</h2>
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button> <button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div> </div>
<div id="potWizardBody"></div> <div id="potWizardBody"></div>
+20 -1
View File
@@ -5837,7 +5837,10 @@ html {
.toastClose:focus-visible, .toastClose:focus-visible,
.potWizardChoice:focus-visible, .potWizardChoice:focus-visible,
.cardSlot:focus-visible, .cardSlot:focus-visible,
.subPanel summary:focus-visible, /* Every summary in the tool, not only the ones nested inside Table Setup. The rulings
are twelve of them and the sidebar's panel names are eight more, and until the Tab
order could reach a summary at all there was no way to notice they had no ring. */
summary:focus-visible,
.seat:focus-visible, .seat:focus-visible,
button:focus-visible, button:focus-visible,
[role="button"]:focus-visible, [role="button"]:focus-visible,
@@ -5858,6 +5861,22 @@ textarea:focus-visible {
outline: none; outline: none;
} }
/* The heading a sheet lands on. It is only ever focused by script, so `:focus` would
put a ring around the title of every sheet every time one opened, on a phone that has
no keyboard to show a ring for. `:focus-visible` asks the browser the question that
actually matters — did this dealer arrive by keyboard — and answers no for a tap.
Offset inward: these headings run to the edge of their sheet and an outward ring
would be clipped by it.
Scoped to the headings on purpose: the confirm dialog names a button as its landing
spot, and a button already has a ring above, drawn outward the way every other
control in the tool draws one. */
h2[data-modal-focus]:focus-visible {
outline: 2px solid #7fc4ff;
outline-offset: -2px;
border-radius: var(--r-sm);
}
/* ---------- Fields on a tablet ---------- /* ---------- Fields on a tablet ----------
The 16px minimum and the 44px touch targets were both written into the phone The 16px minimum and the 44px touch targets were both written into the phone
media query, so every iPad size missed them — measured at 768, 834 and 1024 wide, media query, so every iPad size missed them — measured at 768, 834 and 1024 wide,
@@ -41,7 +41,17 @@ const AddChips = {
]; ];
}, },
// Choosing a preset redraws the three of them so the chosen one can light up, which
// meant the button the dealer had just pressed was deleted while it held focus —
// and a deleted element doesn't hand focus on, it drops it on the body. Measured:
// Space on "Buy-in" left focus nowhere, inside a modal, with the amount changed and
// nothing to say so. See Modal.preservingFocus; the key is the amount, because that
// is what makes one of these three buttons itself.
render() { render() {
Modal.preservingFocus(document.getElementById("addChipsSheet"), () => this.paint());
},
paint() {
const player = this.state.seats[this.seatIndex]; const player = this.state.seats[this.seatIndex];
document.getElementById("addChipsTitle").textContent = document.getElementById("addChipsTitle").textContent =
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`; `Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
@@ -59,6 +69,7 @@ const AddChips = {
btn.type = "button"; btn.type = "button";
btn.className = "addChipsQuickBtn"; btn.className = "addChipsQuickBtn";
btn.classList.toggle("selected", Number(this.draft) === option.value); btn.classList.toggle("selected", Number(this.draft) === option.value);
btn.dataset.focusKey = `quick-${option.value}`;
const label = document.createElement("span"); const label = document.createElement("span");
label.className = "addChipsQuickLabel"; label.className = "addChipsQuickLabel";
@@ -14,6 +14,11 @@ const CardPicker = {
{ code: "C", symbol: "♣", red: false }, { code: "C", symbol: "♣", red: false },
], ],
// Which card in the grid the arrows are holding, as a flat index into
// RANKS x SUITS. Kept across a redraw so the grid doesn't send the dealer back to
// the ace of spades every time they pick something.
cursor: 0,
state: null, state: null,
hooks: null, hooks: null,
// Which set of slots is being filled: a seat's two hole cards, or the five // Which set of slots is being filled: a seat's two hole cards, or the five
@@ -30,6 +35,7 @@ const CardPicker = {
this.target = "seat"; this.target = "seat";
this.seatIndex = seatIndex; this.seatIndex = seatIndex;
this.cardIndex = cardIndex; this.cardIndex = cardIndex;
this.cursor = 0;
this.render(); this.render();
document.getElementById("cardPickerOverlay").hidden = false; document.getElementById("cardPickerOverlay").hidden = false;
}, },
@@ -44,6 +50,7 @@ const CardPicker = {
this.seatIndex = null; this.seatIndex = null;
this.boardLimit = Math.max(1, Math.min(5, limit)); this.boardLimit = Math.max(1, Math.min(5, limit));
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1); this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
this.cursor = 0;
this.render(); this.render();
document.getElementById("cardPickerOverlay").hidden = false; document.getElementById("cardPickerOverlay").hidden = false;
}, },
@@ -138,7 +145,84 @@ const CardPicker = {
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`; return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
}, },
// Every card in the deck, in the order the grid draws them: four suits across,
// thirteen ranks down. The arrow keys and the roving tabindex both work in this
// order, so it is written once rather than rebuilt by two nested loops that have to
// agree.
deck() {
const cards = [];
this.RANKS.forEach(rank => {
this.SUITS.forEach(suit => cards.push({ rank, suit, card: `${rank}${suit.code}` }));
});
return cards;
},
// Where the arrows can actually go. A card already dealt somewhere is a disabled
// button, and a disabled button cannot take focus, so stepping has to keep going in
// the same direction until it finds one that can — and stay put rather than wrap if
// it runs out of grid. Wrapping would put the dealer on a different rank without
// them asking to move rank.
nextCursor(from, step) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
for (let i = from + step; i >= 0 && i < buttons.length; i += step) {
if (!buttons[i].disabled) return i;
}
return from;
},
// One Tab stop for fifty-two cards.
//
// Every card used to be its own stop, which made this sheet fifty-six of them:
// reaching the seven of clubs meant something like forty presses of the only key
// that moves at all without a mouse. The APG's answer for a set of controls like
// this is the roving tabindex — one of them is in the Tab order at a time, and the
// arrows move between them — so the grid now costs one press to enter and one to
// leave, and the arrows bring the row into view on the way, which forty Tab presses
// through a scrolling sheet never reliably did.
//
// No new role is claimed for the container. They are buttons, they announce as
// buttons, and the only thing that changed is how far away they are.
aimCursor(index, { focus = false } = {}) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
if (!buttons.length) return;
const live = buttons[index] && !buttons[index].disabled ? index : this.nextCursor(index, 1);
this.cursor = buttons[live] && !buttons[live].disabled ? live : this.nextCursor(index, -1);
buttons.forEach((btn, i) => {
btn.tabIndex = i === this.cursor && !btn.disabled ? 0 : -1;
});
if (focus && buttons[this.cursor] && !buttons[this.cursor].disabled) {
buttons[this.cursor].focus({ preventScroll: false });
}
},
onGridKey(event) {
const stride = this.SUITS.length;
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
if (step !== undefined) {
event.preventDefault();
this.aimCursor(this.nextCursor(this.cursor, step), { focus: true });
return;
}
if (event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
this.aimCursor(event.key === "Home" ? this.nextCursor(-1, 1) : this.nextCursor(count, -1), { focus: true });
},
// A redraw is not a reason to lose your place. Every button in here is thrown away
// and rebuilt on each one, so whatever held focus was being deleted out from under
// the dealer — see Modal.preservingFocus. The card just picked is the one key that
// can't come back, because it is a dealt card now: the slot it went into is the
// useful place to be instead.
render() { render() {
const sheet = document.getElementById("cardPickerSheet");
Modal.preservingFocus(sheet, () => this.paint(), () => {
const slot = sheet.querySelector("#cardPickerSlots .pickerSlot.active");
if (slot) slot.focus({ preventScroll: true });
});
},
paint() {
const used = this.cardsInUse(); const used = this.cardsInUse();
const values = this.slotValues(); const values = this.slotValues();
@@ -158,6 +242,9 @@ const CardPicker = {
btn.textContent = value || "—"; btn.textContent = value || "—";
if (this.onBoard()) btn.title = this.boardSlotLabel(slot); if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
btn.classList.toggle("cardRed", /[HD]$/i.test(value)); btn.classList.toggle("cardRed", /[HD]$/i.test(value));
// The name this control answers to after a redraw. A slot is always the
// same slot — flop 2 stays flop 2 — so its number is the whole of it.
btn.dataset.focusKey = `slot-${slot}`;
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
this.cardIndex = slot; this.cardIndex = slot;
this.render(); this.render();
@@ -167,38 +254,41 @@ const CardPicker = {
const grid = document.getElementById("cardPickerGrid"); const grid = document.getElementById("cardPickerGrid");
grid.innerHTML = ""; grid.innerHTML = "";
this.RANKS.forEach(rank => { this.deck().forEach(({ rank, suit, card }) => {
this.SUITS.forEach(suit => { const takenBy = used.get(card);
const card = `${rank}${suit.code}`;
const takenBy = used.get(card);
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "pickerCard"; btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red); btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy; btn.disabled = !!takenBy;
if (takenBy) { btn.dataset.focusKey = card;
btn.title = takenBy === "the board" if (takenBy) {
? "Already face up on the board" btn.title = takenBy === "the board"
: `Already dealt to ${takenBy}`; ? "Already face up on the board"
} : `Already dealt to ${takenBy}`;
if ((values[this.cardIndex] || "").toUpperCase() === card) { }
btn.classList.add("selected"); if ((values[this.cardIndex] || "").toUpperCase() === card) {
} btn.classList.add("selected");
}
const rankEl = document.createElement("span"); const rankEl = document.createElement("span");
rankEl.className = "pickerRank"; rankEl.className = "pickerRank";
rankEl.textContent = rank; rankEl.textContent = rank;
const suitEl = document.createElement("span"); const suitEl = document.createElement("span");
suitEl.className = "pickerSuit"; suitEl.className = "pickerSuit";
suitEl.textContent = suit.symbol; suitEl.textContent = suit.symbol;
btn.appendChild(rankEl); btn.appendChild(rankEl);
btn.appendChild(suitEl); btn.appendChild(suitEl);
btn.addEventListener("click", () => this.assign(card)); btn.addEventListener("click", () => this.assign(card));
grid.appendChild(btn); btn.addEventListener("keydown", event => this.onGridKey(event));
}); grid.appendChild(btn);
}); });
// Hand the Tab order back exactly one of them. Done after the grid is built,
// because which card can hold the cursor depends on which are dealt already.
this.aimCursor(this.cursor);
}, },
}; };
+76 -12
View File
@@ -14,12 +14,24 @@
const Modal = { const Modal = {
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay // `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
// must not be a Tab stop. // must not be a Tab stop.
//
// `details > summary` is here because a summary is focusable without anyone giving
// it a tabindex, and leaving it out was not a small omission. The rulings sheet is
// twelve summaries and one Done button; this list saw one control in it, so the Tab
// trap below decided Done was both the first stop and the last and bounced every
// press straight back to it. Measured: eighty Tab presses, one element ever
// reached. Twelve rulings, none of them openable without a mouse.
//
// `:not([tabindex="-1"])` belongs on every line, not only the last. A control taken
// out of the Tab order still matches `button` or `input` — and a roving tabindex,
// which is what the card grid runs on now, is fifty-one of them at a time.
FOCUSABLE: [ FOCUSABLE: [
"a[href]", 'a[href]:not([tabindex="-1"])',
"button:not([disabled])", 'button:not([disabled]):not([tabindex="-1"])',
'input:not([disabled]):not([type="hidden"])', 'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
"select:not([disabled])", 'select:not([disabled]):not([tabindex="-1"])',
"textarea:not([disabled])", 'textarea:not([disabled]):not([tabindex="-1"])',
'details > summary:not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"])', '[tabindex]:not([tabindex="-1"])',
].join(","), ].join(","),
@@ -63,11 +75,22 @@ const Modal = {
this.open.push({ overlay, returnFocus: document.activeElement }); this.open.push({ overlay, returnFocus: document.activeElement });
// Focus the first real control. A dialog that leaves focus on the button // Where the sheet puts the dealer when it opens.
// 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. // This used to be "the first control", full stop — one answer to a question six
// dialogs answer differently, and on the card picker the first control is Clear.
// The sheet for writing a card down opened on the button that rubs one out. The
// APG's rule is the opposite of that: where an action is hard to take back, land
// on the least destructive thing in the dialog.
//
// So a sheet names its own landing spot with `data-modal-focus`, and on all five
// that name one it is the heading. That is Roselli's advice for a dialog with
// more in it than a sentence — give the reader a chance to orient — and here it
// has a second use, because these headings say *which*: which seat, which card,
// which player's chips. A sheet that names nothing still gets the first control,
// and a sheet with no controls at all still gets focus on itself.
const focusable = this.focusableIn(overlay); const focusable = this.focusableIn(overlay);
const target = focusable[0]; const target = overlay.querySelector("[data-modal-focus]") || focusable[0];
if (target) { if (target) {
target.focus({ preventScroll: true }); target.focus({ preventScroll: true });
} else if (sheet) { } else if (sheet) {
@@ -93,6 +116,39 @@ const Modal = {
} }
}, },
// Rebuild part of a sheet without losing the dealer's place in it.
//
// Emptying the element that currently holds focus does not move focus — it destroys
// it, and the browser drops the remains on `<body>`. Measured, on three sheets that
// redraw themselves after every tap: choosing a buy-in preset, picking a card,
// switching which slot is being filled. All three left focus on the document body,
// which inside a modal is nowhere: the next Tab had to be rescued by the wrap above.
//
// Controls that get rebuilt say who they are with `data-focus-key`, and whoever held
// focus is handed back the control with the same name. When that name is gone the
// caller is asked instead, because only it knows where the dealer should be next —
// the card just picked is a dealt card now, and dealt cards are disabled.
preservingFocus(root, rebuild, whenGone) {
const active = document.activeElement;
const inside = !!(active && root.contains(active));
const key = inside ? active.dataset.focusKey : undefined;
rebuild();
// Nothing was taken from under the dealer: either they weren't in this sheet, or
// the control they were standing on is still standing, and still has them. Only
// a rebuild that destroyed the focused element has anything to put right, which
// is why Clear — a button in the header that no redraw touches — keeps focus
// through a redraw it asked for.
if (!inside || active.isConnected) return;
const again = key === undefined ? null
: [...root.querySelectorAll("[data-focus-key]")]
.find(el => el.dataset.focusKey === key && !el.disabled);
if (again) again.focus({ preventScroll: true });
else if (whenGone) whenGone();
},
topmost() { topmost() {
return this.open.length ? this.open[this.open.length - 1] : null; return this.open.length ? this.open[this.open.length - 1] : null;
}, },
@@ -130,12 +186,20 @@ const Modal = {
const first = focusable[0]; const first = focusable[0];
const last = focusable[focusable.length - 1]; const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || !top.overlay.contains(active))) { // Where the dealer stands *in the ring*, which is a different question from
// where they are on the screen. Three things inside these sheets are on purpose
// not in the ring: the heading a sheet lands on, every card in the grid bar the
// one the arrows are holding, and — for a moment after a rebuild — nothing at
// all, because focus has been dropped on the body. -1 covers all three, and the
// wrap has to work from there or the first Shift+Tab off a heading walks out of
// the dialog it was supposed to be held inside.
const at = focusable.indexOf(document.activeElement);
if (event.shiftKey && at <= 0) {
event.preventDefault(); event.preventDefault();
last.focus({ preventScroll: true }); last.focus({ preventScroll: true });
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) { } else if (!event.shiftKey && (at === -1 || at === focusable.length - 1)) {
event.preventDefault(); event.preventDefault();
first.focus({ preventScroll: true }); first.focus({ preventScroll: true });
} }
@@ -108,7 +108,25 @@ const PotWizard = {
requestAnimationFrame(update); requestAnimationFrame(update);
}, },
// Confirming a round replaces the whole step, buttons and all, and the button it
// replaces first is the Confirm the dealer just pressed — so on a table with side
// pots, paying the main pot dropped focus on the body and the next Tab had to start
// over. There is no keeping them where they were here: the control is gone and the
// question has changed. The new question is where they should be, so focus lands on
// the sentence asking it — "that side pot still leaves $240 unassigned, who's next
// best?" — rather than on the button that answers it.
renderStepInto() { renderStepInto() {
const modal = document.getElementById("potWizardModal");
Modal.preservingFocus(modal, () => this.paintStep(), () => {
const asking = modal.querySelector("#potWizardBody .potWizardPrompt")
|| document.getElementById("potWizardTitle");
if (!asking) return;
asking.tabIndex = -1;
asking.focus({ preventScroll: true });
});
},
paintStep() {
// Auto-resolve any round where only one player is left standing — nothing to ask. // Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries); let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) { while (contenders.length === 1) {
@@ -191,7 +191,19 @@ const SeatMenu = {
return el; return el;
}, },
// Stepping into the move picker throws the whole list away, and the first thing it
// throws away is the "Move this seat" the dealer just pressed — which left focus on
// the body, inside a sheet that had just changed into a different sheet. The
// heading is where to be: in move mode it has stopped saying who this is and
// started saying what is being asked — "Move Alexandra to…".
render() { render() {
Modal.preservingFocus(document.getElementById("seatMenuOverlay"), () => this.paint(), () => {
const title = document.getElementById("seatMenuTitle");
if (title) title.focus({ preventScroll: true });
});
},
paint() {
const player = this.state.seats[this.seatIndex]; const player = this.state.seats[this.seatIndex];
const title = document.getElementById("seatMenuTitle"); const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle"); const subtitle = document.getElementById("seatMenuSubtitle");
+6
View File
@@ -43,6 +43,11 @@ const CardPicker = load("cardPicker");
// navigator.wakeLock — see wakeLock.test.js. // navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock"); const WakeLock = load("wakeLock");
// Modal is DOM behaviour through and through, but two pieces of it answer here: the
// list of what counts as a Tab stop, which is a string, and preservingFocus, which asks
// so little of a document that a stand-in can answer it. See theKeyboardInASheet.
const Modal = load("modal");
// The seat menu's list — which actions a seat is offered and which group each one // The seat menu's list — which actions a seat is offered and which group each one
// lands in — is decided before anything is drawn, so actionsFor() answers here. Only // lands in — is decided before anything is drawn, so actionsFor() answers here. Only
// render() and open() touch the DOM. // render() and open() touch the DOM.
@@ -105,6 +110,7 @@ module.exports = {
CardPicker, CardPicker,
WakeLock, WakeLock,
SeatMenu, SeatMenu,
Modal,
makeTable, makeTable,
chipsOnTable, chipsOnTable,
contributions, contributions,
+374
View File
@@ -0,0 +1,374 @@
// Where a sheet puts the dealer, and whether they can get anywhere from there.
//
// Modal exists because six overlays were each missing the same three things, and it
// fixed all six at once by answering one question the same way everywhere: focus the
// first control, and hold Tab between the first and the last. Measured this round, that
// single answer had gone wrong in three separate directions.
//
// It could not see a <summary>. The rulings sheet is twelve of them and one Done
// button, so the trap decided Done was both ends of the ring and bounced every press
// back to it: eighty Tab presses, one element ever reached, twelve rulings that could
// not be opened without a mouse.
//
// "The first control" was the card picker's Clear. The sheet a dealer opens to write a
// card down opened on the button that rubs one out — the one thing the APG says to keep
// a dialog off. And "the first control" is not an answer six dialogs share: Roselli's
// rule is that a dialog long enough to scroll should land on its heading so the reader
// can orient, and a short question with one irreversible answer should land on the
// answer that leaves them no worse off.
//
// And a sheet that redraws itself was deleting the control that held focus. Choosing a
// buy-in, picking a card, switching slots, confirming a side pot: all four left focus on
// the document body, which inside a modal is nowhere at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Modal, CardPicker } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const src = name => fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
// The markup of one overlay, from its opening div to the next one's.
const OVERLAY_ORDER = ["addChipsOverlay", "rulingsOverlay", "seatMenuOverlay",
"cardPickerOverlay", "confirmOverlay", "potWizardOverlay"];
const overlay = id => {
const at = PAGE.indexOf(`<div id="${id}"`);
assert.ok(at >= 0, `there is no ${id}`);
const ends = OVERLAY_ORDER
.map(other => PAGE.indexOf(`<div id="${other}"`, at + 1))
.filter(i => i > at);
return PAGE.slice(at, ends.length ? Math.min(...ends) : PAGE.length);
};
// A document small enough to answer the handful of questions these functions ask of
// one, and honest about the two that matter: a detached node knows it is detached, and
// a disabled button refuses focus.
function fakeSheet(specs) {
const doc = { activeElement: null };
const nodes = specs.map(spec => ({
dataset: spec.key === undefined ? {} : { focusKey: spec.key },
disabled: !!spec.disabled,
isConnected: true,
tabIndex: 0,
label: spec.key || spec.label,
focus() {
if (this.disabled) return;
doc.activeElement = this;
},
}));
const root = {
nodes,
contains: el => nodes.includes(el),
querySelectorAll: () => nodes.filter(n => n.dataset.focusKey !== undefined),
};
return { doc, root, nodes };
}
// preservingFocus and the picker's cursor both reach for the global document. They are
// given one for the length of a test and it is taken away again afterwards.
function withDocument(doc, body) {
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
const before = globalThis.document;
globalThis.document = doc;
try {
return body();
} finally {
if (had) globalThis.document = before;
else delete globalThis.document;
}
}
// ---------------------------------------------------------------------------
describe("what counts as somewhere you can Tab to", () => {
const selectors = Modal.FOCUSABLE.split(",");
// The whole of the rulings sheet hung on this one omission.
test("a summary is a control, because the browser already treats it as one", () => {
assert.ok(selectors.some(s => /details\s*>\s*summary/.test(s)),
"a <details> summary is focusable without a tabindex, and this list can't see it");
});
// Left off, a card taken out of the Tab order still matches `button`, and the grid
// goes back to being fifty-two stops with a roving tabindex painted on top.
test("every line excludes what has been taken out of the Tab order, not just the last", () => {
selectors.forEach(selector => {
assert.ok(/:not\(\[tabindex="-1"\]\)/.test(selector) || /^\s*\[tabindex\]/.test(selector),
`\`${selector.trim()}\` still matches controls that are not Tab stops`);
});
});
test("and the sheet itself is still off the list", () => {
assert.ok(/\[tabindex\]:not\(\[tabindex="-1"\]\)/.test(Modal.FOCUSABLE));
});
});
describe("where each sheet lands the dealer", () => {
const LANDS_ON_ITS_HEADING = [
["cardPickerOverlay", "cardPickerTitle"],
["addChipsOverlay", "addChipsTitle"],
["rulingsOverlay", "rulingsTitle"],
["potWizardOverlay", "potWizardTitle"],
["seatMenuOverlay", "seatMenuTitle"],
];
LANDS_ON_ITS_HEADING.forEach(([id, heading]) => {
test(`${id} opens on the line that says which`, () => {
const markup = overlay(id);
const tag = markup.match(new RegExp(`<h2 id="${heading}"[^>]*>`));
assert.ok(tag, `${heading} is not an h2 in ${id}`);
assert.match(tag[0], /data-modal-focus/);
assert.match(tag[0], /tabindex="-1"/,
"a heading that is a Tab stop is a Tab stop the dealer has to press past");
});
});
// The APG's own example of when to land on a control rather than a heading, and the
// reason: one of the two answers cannot be taken back.
test("the confirm dialog is the exception, and it lands on Cancel", () => {
const markup = overlay("confirmOverlay");
const cancel = markup.match(/<button id="confirmCancelBtn"[^>]*>/);
assert.ok(cancel);
assert.match(cancel[0], /data-modal-focus/);
assert.ok(!/<button id="confirmAcceptBtn"[^>]*data-modal-focus/.test(markup),
"the dialog lands on the answer that cannot be taken back");
});
test("no sheet names two landing spots, which would be a coin toss", () => {
OVERLAY_ORDER.forEach(id => {
const found = (overlay(id).match(/data-modal-focus/g) || []).length;
assert.equal(found, 1, `${id} names ${found} landing spots`);
});
});
test("the card picker no longer opens on Clear", () => {
const markup = overlay("cardPickerOverlay");
assert.ok(markup.indexOf('id="cardPickerTitle"') < markup.indexOf('id="cardPickerClearBtn"'),
"the heading has moved below the button it was there to keep focus off");
assert.ok(!/id="cardPickerClearBtn"[^>]*data-modal-focus/.test(markup));
});
// The landing spot is deliberately outside the ring, and a wrap that only knows
// "is this the first one" walks straight out of the dialog on the first Shift+Tab.
test("the trap wraps from outside the ring as well as from its ends", () => {
const modal = src("modal");
const at = modal.indexOf("const at = focusable.indexOf(");
assert.ok(at >= 0, "the Tab trap no longer asks where the dealer is in the ring");
const body = modal.slice(at, modal.indexOf("\n },", at));
assert.match(body, /shiftKey && at <= 0/);
assert.match(body, /at === -1/);
});
});
describe("a redraw that does not drop the dealer", () => {
test("focus comes back to the control with the same name", () => {
const { doc, root, nodes } = fakeSheet([
{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
doc.activeElement = nodes[1];
withDocument(doc, () => Modal.preservingFocus(root, () => {
// What a rebuild does: the old buttons go, identical new ones arrive.
nodes.forEach(n => { n.isConnected = false; });
root.nodes = root.querySelectorAll = null;
const rebuilt = fakeSheet([{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
rebuilt.nodes.forEach(n => { n.focus = () => { doc.activeElement = n; }; });
root.querySelectorAll = () => rebuilt.nodes;
root.contains = el => rebuilt.nodes.includes(el);
}));
assert.equal(doc.activeElement.dataset.focusKey, "quick-1000");
});
// The card just picked is a dealt card now, and a dealt card is a disabled button.
// There is no coming back to it, so the caller is asked where to go instead.
test("a name that comes back disabled hands the decision to the caller", () => {
const { doc, root, nodes } = fakeSheet([{ key: "AS" }, { key: "KH" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
nodes[0].disabled = true;
}, () => { asked = true; }));
assert.ok(asked, "focus was left on the body of a modal");
});
test("and a name that is simply gone asks too", () => {
const { doc, root, nodes } = fakeSheet([{ key: "confirm" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
root.querySelectorAll = () => [];
}, () => { asked = true; }));
assert.ok(asked);
});
// Clear is in the header. Nothing rebuilds it, so nothing should move it — a redraw
// the dealer asked for is not a reason to take them somewhere else.
test("a control that survived the redraw is left exactly where it was", () => {
const { doc, root, nodes } = fakeSheet([{ label: "Clear" }, { key: "AS" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[1].isConnected = false;
}, () => { asked = true; }));
assert.equal(doc.activeElement, nodes[0]);
assert.ok(!asked);
});
test("and a dealer who was never in this sheet is not dragged into it", () => {
const { doc, root } = fakeSheet([{ key: "AS" }]);
const elsewhere = { dataset: {}, isConnected: true };
doc.activeElement = elsewhere;
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {}, () => { asked = true; }));
assert.equal(doc.activeElement, elsewhere);
assert.ok(!asked);
});
// Four sheets redraw themselves on a tap. All four go through this now, and each
// one hands over the piece of itself it is about to throw away.
[["addChips", "render()", "addChipsSheet"],
["cardPicker", "render()", "cardPickerSheet"],
["seatMenu", "render()", "seatMenuOverlay"],
["potWizard", "renderStepInto()", "potWizardModal"]].forEach(([module, method, root]) => {
test(`${module}'s ${method} redraws through it`, () => {
const text = src(module);
const at = text.indexOf(`\n ${method} {`);
assert.ok(at >= 0, `${module} has no ${method}`);
const body = text.slice(at, text.indexOf("\n },", at));
assert.match(body, /Modal\.preservingFocus\(/,
`${module} rebuilds its controls without saying what to do about focus`);
assert.ok(body.includes(`"${root}"`),
`${module} hands preservingFocus something other than ${root}`);
});
});
});
describe("fifty-two cards, one Tab stop", () => {
test("the deck is the grid's order, written once", () => {
const deck = CardPicker.deck();
assert.equal(deck.length, 52);
assert.equal(new Set(deck.map(c => c.card)).size, 52);
// Four suits across, thirteen ranks down — which is what the arrow keys step by.
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
assert.equal(deck[4].card, "KS");
assert.equal(deck[51].card, "2C");
});
const grid = keys => keys.map(key => ({
dataset: { focusKey: key.replace("!", "") },
disabled: key.includes("!"),
tabIndex: 0,
focus() {},
}));
const withGrid = (cards, body) => {
const nodes = grid(cards);
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
};
test("a step lands on a card that can actually take focus", () => {
// 7C is dealt, so stepping down the clubs from the eight has to pass over it.
withGrid(["8C", "7C!", "6C"], () => {
assert.equal(CardPicker.nextCursor(0, 1), 2);
});
});
test("and keeps going as far as it has to", () => {
withGrid(["AS", "KS!", "QS!", "JS!", "TS"], () => {
assert.equal(CardPicker.nextCursor(0, 1), 4);
});
});
// Wrapping would answer "move me down one rank" by moving the dealer to a different
// suit, which is not what they asked and not where they would look.
test("the edge of the grid is the edge, not a way round to the other side", () => {
withGrid(["AS", "AH", "AD", "AC"], () => {
assert.equal(CardPicker.nextCursor(3, 1), 3);
assert.equal(CardPicker.nextCursor(0, -1), 0);
});
});
test("only one card is in the Tab order at a time", () => {
withGrid(["AS", "AH", "AD", "AC"], nodes => {
CardPicker.aimCursor(2);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
});
});
// Which happens after every pick: the cursor is sitting on a card that has just
// become a dealt card.
test("a cursor left on a dealt card moves to one that can hold it", () => {
withGrid(["AS!", "AH", "AD", "AC"], nodes => {
CardPicker.aimCursor(0);
assert.equal(CardPicker.cursor, 1);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, 0, -1, -1]);
});
});
test("and looks backwards when there is nothing left ahead of it", () => {
withGrid(["2S", "2H", "2D!", "2C!"], () => {
CardPicker.aimCursor(3);
assert.equal(CardPicker.cursor, 1);
});
});
test("the arrows step by a row of suits, not by a guess", () => {
const picker = src("cardPicker");
const at = picker.indexOf("onGridKey(event)");
assert.ok(at >= 0, "the grid has no arrow keys");
const body = picker.slice(at, picker.indexOf("\n },", at));
assert.match(body, /const stride = this\.SUITS\.length/);
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
assert.match(body, /"Home"/);
assert.match(body, /"End"/);
});
test("a fresh sheet starts at the top of the deck", () => {
const picker = src("cardPicker");
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
.forEach(signature => {
const at = picker.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
assert.match(picker.slice(at, picker.indexOf("this.render();", at)), /this\.cursor = 0;/);
});
});
});
describe("seeing where you are", () => {
// Until a summary could be Tabbed to at all, there was no way to notice that
// twenty of them had no ring.
test("a summary gets the tool's own focus ring, not the browser's", () => {
const at = CSS.indexOf("---------- Keyboard focus ----------");
assert.ok(at >= 0);
const block = CSS.slice(at, CSS.indexOf("outline: 2px solid #7fc4ff", at));
assert.match(block, /(^|\n)summary:focus-visible,/);
});
// Only script ever focuses these, so `:focus` would ring the title of every sheet
// on a phone that has no keyboard to ring it for.
test("the landing heading rings for a keyboard and stays quiet for a thumb", () => {
const at = CSS.indexOf('h2[data-modal-focus]:focus-visible {');
assert.ok(at >= 0, "the heading a sheet lands on shows nothing when it takes focus");
const rule = CSS.slice(at, CSS.indexOf("}", at));
assert.match(rule, /outline: 2px solid #7fc4ff/);
// These headings run to the edge of their sheet; an outward ring is clipped.
assert.match(rule, /outline-offset: -2px/);
assert.ok(!/\[data-modal-focus\]:focus\b(?!-)/.test(CSS),
"a plain :focus rule would ring the title on every tap");
});
});