// 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 . 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(`
= 0, `there is no ${id}`); const ends = OVERLAY_ORDER .map(other => PAGE.indexOf(`
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
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(`

]*>`)); 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(/