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.
375 lines
17 KiB
JavaScript
375 lines
17 KiB
JavaScript
// 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");
|
|
});
|
|
});
|