diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js index 3478325..d6f5f44 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js @@ -7,7 +7,20 @@ // (in practice: Undo). Only one is ever on screen; a second replaces the first. const Toast = { DEFAULT_DURATION_MS: 8000, + + // What the clock is set back to when the dealer lets go of a toast. + // + // Pausing while it is touched or focused is only half the job. Let go at 7.9 + // seconds of 8 and the message goes while the thumb is still travelling: the + // timer that matters once somebody has reached for a toast is the time to act + // on it, not what happened to be left of the time to notice it. + RESUME_MS: 2500, + timer: null, + endsAt: 0, + remaining: 0, + // Where focus was standing when this arrived. + returnFocusTo: null, show(message, options = {}) { const host = document.getElementById("toastHost"); @@ -17,7 +30,11 @@ const Toast = { const toast = document.createElement("div"); toast.className = "toast"; - toast.setAttribute("role", "status"); + // No role on the toast itself. #toastHost is the live region, and it is in the + // markup — which is what makes it observed. A region that arrives already + // carrying its text has not been watched by anything yet, and is routinely + // never spoken at all; one nested inside a region that is watched only buys a + // second reading of the same sentence. const text = document.createElement("span"); text.className = "toastText"; @@ -29,6 +46,12 @@ const Toast = { action.type = "button"; action.className = "toastAction"; action.textContent = options.actionLabel; + // Which keyboard shortcut, if any, presses this. Measured on a desktop, + // this button is the 67th of the page's 68 tab stops, and it is gone in + // eight seconds: reaching it by Tab is not a thing anybody can do. Only + // the toasts that say so opt in — "Turn off" and "Reload" are not things + // a stray keypress should be able to reach. + if (options.shortcut) action.dataset.shortcut = options.shortcut; action.addEventListener("click", () => { this.dismiss(); options.onAction(); @@ -44,6 +67,27 @@ const Toast = { close.addEventListener("click", () => this.dismiss()); toast.appendChild(close); + // Read after dismiss() above has had its chance to put focus back, so that a + // toast replacing another remembers where the dealer actually is. + const active = document.activeElement; + this.returnFocusTo = active && active !== document.body ? active : null; + + // A message with a button in it is on a timer the reader has to beat, which is + // what WCAG's Timing Adjustable is about. Touching it or tabbing into it stops + // the clock; letting go starts it again. + toast.addEventListener("pointerenter", () => this.hold()); + toast.addEventListener("focusin", event => { + // Where focus came *from* beats where it was when this appeared: a dealer + // who moves on and then tabs back into the toast should be put back where + // they had got to, not where they were eight seconds ago. + if (event.relatedTarget && !toast.contains(event.relatedTarget)) { + this.returnFocusTo = event.relatedTarget; + } + this.hold(); + }); + ["pointerleave", "focusout"].forEach(type => + toast.addEventListener(type, () => this.resume())); + host.appendChild(toast); // Next frame, so the entrance transition actually runs. requestAnimationFrame(() => { @@ -51,10 +95,51 @@ const Toast = { this.publishHeight(); }); - this.timer = setTimeout( - () => this.dismiss(), - options.duration || this.DEFAULT_DURATION_MS - ); + this.arm(options.duration || this.DEFAULT_DURATION_MS); + }, + + arm(ms) { + if (this.timer) clearTimeout(this.timer); + this.endsAt = Date.now() + ms; + this.timer = setTimeout(() => this.dismiss(), ms); + }, + + // Is the dealer holding this open — a finger on it, or the keyboard inside it? + heldOpen() { + if (typeof document === "undefined") return false; + const host = document.getElementById("toastHost"); + const toast = host && host.firstElementChild; + if (!toast) return false; + if (document.activeElement && toast.contains(document.activeElement)) return true; + // :hover rather than a flag of our own, so a pointer that left the window + // without a pointerleave — or one that never sent pointerenter because the + // toast appeared underneath it — is answered by the same question either way. + try { + return typeof toast.matches === "function" && toast.matches(":hover"); + } catch (err) { + return false; + } + }, + + hold() { + if (!this.timer) return; + clearTimeout(this.timer); + this.timer = null; + this.remaining = Math.max(0, this.endsAt - Date.now()); + }, + + resume() { + // `focusout` arrives before the new focus lands, so "is anything in here still + // focused?" has no true answer until the next tick. pointerleave is deferred + // with it rather than against it: leaving with the keyboard still inside must + // not restart the clock. + setTimeout(() => { + if (this.timer || this.heldOpen()) return; + if (typeof document === "undefined") return; + const host = document.getElementById("toastHost"); + if (!host || !host.firstElementChild) return; + this.arm(Math.max(this.remaining, this.RESUME_MS)); + }, 0); }, dismiss() { @@ -62,12 +147,25 @@ const Toast = { clearTimeout(this.timer); this.timer = null; } + this.remaining = 0; // The same guard publishHeight below already carries, for the same reason: the // bar dismisses a toast on the way into every action it commits, and that path // is otherwise plain bookkeeping that answers without a document. if (typeof document === "undefined") return; const host = document.getElementById("toastHost"); - if (host) host.innerHTML = ""; + if (host) { + // Never leave the keyboard standing on nothing. A control removed while it + // has focus hands focus to , and on this page that is 66 tab stops + // back from where the dealer was — so whoever pressed Undo, or ✕, or was + // simply reading when a second toast arrived, goes back where they were. + const active = document.activeElement; + const goBack = host.contains(active) ? this.returnFocusTo : null; + host.innerHTML = ""; + if (goBack && goBack.isConnected && typeof goBack.focus === "function") { + goBack.focus(); + } + } + this.returnFocusTo = null; this.publishHeight(); }, diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index a5139c3..f32b5d8 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -61,6 +61,7 @@ document.addEventListener("DOMContentLoaded", () => { Toast.show(message, { actionLabel: "Undo", + shortcut: "undo", onAction: () => { restoreState(snapshot); Toast.show("Undone.", { duration: 2500 }); @@ -453,6 +454,24 @@ document.addEventListener("DOMContentLoaded", () => { return true; }; + // The toast's Undo, pressed the same way the bar's is: by pressing the button that + // is actually on the screen, not by running a second copy of what it does. + // + // It has to be reachable. Every whole-step undo — next hand, resolve pot, a seat + // edited — clears the per-action history on its way past, so the bar has no pill to + // press and this button is the only way back. Measured on a desktop, it is the 67th + // of the page's 68 tab stops and it is gone in eight seconds, so U was the key the + // sidebar legend advertised and nothing answered. + // + // Only a button that named this shortcut, so the wake lock's "Turn off" and the + // other-tab warning's "Reload" are not things a stray U can reach. + const pressToastAction = shortcut => { + const btn = document.querySelector(`#toastHost .toastAction[data-shortcut="${shortcut}"]`); + if (!btn) return false; + btn.click(); + return true; + }; + const quickRaiseButton = label => [...document.querySelectorAll("#actionBar .raiseQuickBtn")] .find(b => b.querySelector(".actionBtnLabel").textContent === label); @@ -473,7 +492,10 @@ document.addEventListener("DOMContentLoaded", () => { // Dealing is an advance too — Enter should carry a dealer through setup, deal, // streets and showdown without reaching for the trackpad. advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"), - undo: () => pressInBar(".actionUndo"), + // The toast first, because it is the more recent of the two. They can both be + // up at once — a seat edited mid-hand leaves the bar's pill alone — and the one + // the dealer just caused is the one on the screen saying what it will take back. + undo: () => pressToastAction("undo") || pressInBar(".actionUndo"), cancel: () => pressInBar(".raiseCancel"), }); @@ -493,6 +515,7 @@ document.addEventListener("DOMContentLoaded", () => { Toast.show(message, { actionLabel: "Undo", + shortcut: "undo", onAction: () => { restoreState(snapshot); Toast.show("Undone.", { duration: 2500 }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js index 35f1fb5..c20eeda 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js @@ -30,7 +30,9 @@ const Shortcuts = { { keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" }, { keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" }, { keys: ["A"], action: "allIn", what: "Put this player all-in" }, - { keys: ["U"], action: "undo", what: "Take back the last action" }, + // Both undos, because there are two and a dealer has no reason to know that: + // the bar's pill takes back a bet, and the toast's takes back a whole step. + { keys: ["U"], action: "undo", what: "Take back the last thing you did" }, { keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" }, { keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" }, ], diff --git a/tests/holdem/load.js b/tests/holdem/load.js index d25cbf3..44512c8 100644 --- a/tests/holdem/load.js +++ b/tests/holdem/load.js @@ -43,9 +43,11 @@ const CardPicker = load("cardPicker"); // navigator.wakeLock — see wakeLock.test.js. const WakeLock = load("wakeLock"); -// feedback.js exports two namespaces and a third; Unavailable is the one with anything -// to test, and render.js and cardPicker.js reach for it as a global the way the browser -// hands it to them. See theUnansweredPress. +// feedback.js exports two namespaces and a third. Two of them answer here: Unavailable, +// which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus +// bookkeeping ask little enough of a document that a stand-in can answer (see +// theWayBack). All three are also set as globals, the way the browser hands them to +// render.js and cardPicker.js. const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js")); globalThis.Toast = Toast; globalThis.Dialog = Dialog; @@ -125,6 +127,7 @@ module.exports = { SeatMenu, Modal, QuickSplit, + Toast, Unavailable, makeTable, chipsOnTable, diff --git a/tests/holdem/theWayBack.test.js b/tests/holdem/theWayBack.test.js new file mode 100644 index 0000000..8ebc69e --- /dev/null +++ b/tests/holdem/theWayBack.test.js @@ -0,0 +1,333 @@ +// Undo, and whether a dealer can actually get to it. +// +// This tool prefers undo to confirmation — NN/g's own preference, and `runUndoable` +// says so in as many words: "it now just happens, and the toast offers the way back for +// the few seconds anyone would notice a misclick." That makes the toast's Undo the +// safety net under the biggest things the tool does. Three ways it wasn't one: +// +// * Every whole-step undo clears the per-action history on its way past, so the bar +// has no pill left and this button is the only way back. Measured on a desktop it +// is the 67th of the page's 68 tab stops, and it is gone in eight seconds. `U` — +// the key the sidebar legend advertises as taking back the last action — found +// nothing at all and did nothing. +// * The eight seconds ran whatever the dealer was doing. A cursor resting on the +// toast did not stop it; nor did tabbing onto the button itself. Measured: focus +// the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to +// — the offer withdrawn and the dealer's place in the page with it. +// * Each toast carried its own role="status" inside #toastHost, which is already a +// live region in the markup. A region nested in a region, announced twice. +// +// The timing rules here are the usual ones: three to four seconds for a plain +// confirmation, six to ten for anything carrying a button, pause on hover and on +// keyboard focus, and always a manual way out — which is what WCAG 2.2.1 asks of +// content that disappears on a clock. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const { Toast } = require("./load"); + +const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem"); +const MAIN = fs.readFileSync(path.join(SRC, "main.js"), "utf8"); +const FEEDBACK = fs.readFileSync(path.join(SRC, "feedback.js"), "utf8"); +const SHORTCUTS = fs.readFileSync(path.join(SRC, "shortcuts.js"), "utf8"); +const PAGE = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); + +// --------------------------------------------------------------------------- +// Enough of a document for a toast: a host it can be put into, elements that can hold +// listeners and focus, and a `:hover` that a test can turn on. + +let active = null; + +const node = (tag, className) => { + const self = { + tagName: String(tag).toUpperCase(), + className: className || "", + textContent: "", + dataset: {}, + children: [], + parent: null, + isConnected: true, + hovered: false, + listeners: {}, + attrs: {}, + classList: { add() {} }, + appendChild(child) { child.parent = self; self.children.push(child); return child; }, + addEventListener(type, fn) { (self.listeners[type] = self.listeners[type] || []).push(fn); }, + setAttribute(name, value) { self.attrs[name] = String(value); }, + getAttribute(name) { return name in self.attrs ? self.attrs[name] : null; }, + contains(other) { + for (let n = other; n; n = n.parent) if (n === self) return true; + return false; + }, + matches(selector) { return selector === ":hover" ? self.hovered : false; }, + focus() { active = self; }, + getBoundingClientRect: () => ({ height: 44 }), + // Fire one of the listeners the toast hung on itself. + fire(type, event) { (self.listeners[type] || []).forEach(fn => fn(event || {})); }, + find(className) { + if (self.className === className) return self; + for (const child of self.children) { + const hit = child.find(className); + if (hit) return hit; + } + return null; + }, + }; + Object.defineProperty(self, "firstElementChild", { get: () => self.children[0] || null }); + Object.defineProperty(self, "innerHTML", { + get: () => "", + set(value) { + if (value !== "") return; + self.children.forEach(child => { child.parent = null; child.isConnected = false; }); + self.children.length = 0; + }, + }); + return self; +}; + +let host; +const openDocument = () => { + host = node("div", "toastHost"); + host.id = "toastHost"; + active = null; + global.document = { + getElementById: id => (id === "toastHost" ? host : null), + createElement: tag => node(tag), + documentElement: { style: { setProperty() {} } }, + }; + Object.defineProperty(global.document, "activeElement", { get: () => active }); + global.requestAnimationFrame = fn => fn(); +}; + +const toastOnScreen = () => host.firstElementChild; +const undoButton = () => toastOnScreen() && toastOnScreen().find("toastAction"); + +// The toast's own timers are the thing under test, so they are driven rather than +// waited on. `resume` defers to the next tick on purpose — see feedback.js — so every +// release is followed by a tick of 1 to let that land. +const settle = t => t.mock.timers.tick(1); + +const show = (options = {}) => Toast.show(options.message || "Button moved on.", options); + +test.beforeEach(t => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + openDocument(); +}); + +test.afterEach(() => { + Toast.dismiss(); + delete global.document; + delete global.requestAnimationFrame; +}); + +// --------------------------------------------------------------------------- + +describe("a toast nobody touches", () => { + test("stays for the time it was given, and then goes", t => { + show({ duration: 5000 }); + t.mock.timers.tick(4900); + assert.ok(toastOnScreen(), "it left early"); + t.mock.timers.tick(200); + assert.equal(toastOnScreen(), null, "it outstayed its duration"); + }); + + test("and the default is the one a message with a button in it gets", () => { + // Three to four seconds for a plain confirmation, six to ten for anything + // carrying a button. Every plain one here names its own shorter duration; this + // default is what the undoable steps and the wake lock fall back to. + assert.ok(Toast.DEFAULT_DURATION_MS >= 6000 && Toast.DEFAULT_DURATION_MS <= 10000, + `${Toast.DEFAULT_DURATION_MS}ms is outside the range for a toast with an action in it`); + }); +}); + +describe("a toast the dealer has reached for", () => { + test("a finger on it stops the clock", t => { + show({ duration: 8000 }); + t.mock.timers.tick(1000); + const toast = toastOnScreen(); + toast.hovered = true; + toast.fire("pointerenter"); + + t.mock.timers.tick(30000); + assert.ok(toastOnScreen(), "eight seconds ran out under a finger that was holding it"); + }); + + test("and so does tabbing onto the button", t => { + show({ duration: 8000, actionLabel: "Undo", onAction: () => {} }); + const toast = toastOnScreen(); + undoButton().focus(); + toast.fire("focusin", { relatedTarget: null }); + + t.mock.timers.tick(30000); + assert.ok(toastOnScreen(), "the offer was withdrawn while the keyboard was standing on it"); + assert.equal(active, undoButton(), "and focus went with it"); + }); + + test("letting go starts it again", t => { + show({ duration: 8000 }); + const toast = toastOnScreen(); + toast.hovered = true; + toast.fire("pointerenter"); + t.mock.timers.tick(30000); + + toast.hovered = false; + toast.fire("pointerleave"); + settle(t); + t.mock.timers.tick(7000); + assert.ok(toastOnScreen(), "it left before the time it still had"); + t.mock.timers.tick(1200); + assert.equal(toastOnScreen(), null, "letting go did not restart the clock at all"); + }); + + test("and letting go late still leaves time to act", t => { + // The half of this that pausing alone doesn't fix: touch it at 7.9 seconds of + // 8, let go, and what is left of the clock to *notice* the message is 100ms — + // gone while the thumb is still travelling to Undo. + show({ duration: 8000 }); + const toast = toastOnScreen(); + t.mock.timers.tick(7900); + toast.hovered = true; + toast.fire("pointerenter"); + t.mock.timers.tick(3000); + toast.hovered = false; + toast.fire("pointerleave"); + settle(t); + + t.mock.timers.tick(Toast.RESUME_MS - 500); + assert.ok(toastOnScreen(), "it went in the 100ms that was left rather than the time to act"); + t.mock.timers.tick(700); + assert.equal(toastOnScreen(), null, "and then it never went at all"); + }); + + test("a cursor leaving while the keyboard is still inside does not restart it", t => { + // Two ways to hold it open, one timer. The pointer leaving is not an answer to + // "is anybody still using this?" + show({ duration: 8000, actionLabel: "Undo", onAction: () => {} }); + const toast = toastOnScreen(); + toast.hovered = true; + toast.fire("pointerenter"); + undoButton().focus(); + toast.fire("focusin", { relatedTarget: null }); + + toast.hovered = false; + toast.fire("pointerleave"); + settle(t); + t.mock.timers.tick(30000); + assert.ok(toastOnScreen(), "the clock restarted under a focused button"); + }); +}); + +describe("what happens to focus when it goes", () => { + test("the dealer is put back where they had got to", t => { + const elsewhere = node("button"); + show({ actionLabel: "Undo", onAction: () => {} }); + const toast = toastOnScreen(); + + elsewhere.focus(); + undoButton().focus(); + toast.fire("focusin", { relatedTarget: elsewhere }); + Toast.dismiss(); + settle(t); + + assert.equal(active, elsewhere, + "focus was left on a button that no longer exists, which hands it to "); + }); + + test("and not somewhere they left eight seconds ago", t => { + // Captured when focus crosses into the toast, not when the toast appeared: a + // dealer who carries on working and then tabs back should land where they got + // to, not where they were when the message arrived. + const whenItArrived = node("button"); + const whereTheyGotTo = node("button"); + whenItArrived.focus(); + show({ actionLabel: "Undo", onAction: () => {} }); + const toast = toastOnScreen(); + + whereTheyGotTo.focus(); + undoButton().focus(); + toast.fire("focusin", { relatedTarget: whereTheyGotTo }); + Toast.dismiss(); + settle(t); + + assert.equal(active, whereTheyGotTo); + }); + + test("focus is left alone when it was never in the toast", () => { + const elsewhere = node("button"); + show({ actionLabel: "Undo", onAction: () => {} }); + elsewhere.focus(); + + Toast.dismiss(); + assert.equal(active, elsewhere, "dismissing a toast nobody was in moved the dealer"); + }); + + test("and a control that has gone since is not reached for", () => { + const gone = node("button"); + show({ actionLabel: "Undo", onAction: () => {} }); + const toast = toastOnScreen(); + gone.focus(); + undoButton().focus(); + toast.fire("focusin", { relatedTarget: gone }); + // A re-render between the toast going up and coming down. This happens on every + // undo: restoreState rebuilds the table. + gone.isConnected = false; + + assert.doesNotThrow(() => Toast.dismiss()); + }); +}); + +describe("the key that reaches it", () => { + test("a toast says which shortcut presses its button", () => { + show({ actionLabel: "Undo", shortcut: "undo", onAction: () => {} }); + assert.equal(undoButton().dataset.shortcut, "undo"); + }); + + test("and one that named none is not reachable by a stray keypress", () => { + // "Turn off" on the wake lock and "Reload" on the other-tab warning both live + // in this same strip. Neither is a thing U should be able to do. + show({ actionLabel: "Turn off", onAction: () => {} }); + assert.equal(undoButton().dataset.shortcut, undefined); + }); + + test("both of the undoable paths opt in", () => { + // runUndoable, for whole steps, and seatAction, for a gesture on a seat. + const offers = MAIN.split('actionLabel: "Undo"').length - 1; + const named = MAIN.split('shortcut: "undo"').length - 1; + assert.equal(offers, 2, "the number of Undo toasts has changed"); + assert.equal(named, offers, "an Undo the keyboard cannot reach"); + }); + + test("the key presses the button rather than running its own copy of it", () => { + // The house rule for every other shortcut in this file: press what is on the + // screen, so there is no second code path to drift out of step with it. + assert.match(MAIN, /#toastHost \.toastAction\[data-shortcut=/); + assert.match(MAIN, /undo: \(\) => pressToastAction\("undo"\) \|\| pressInBar\("\.actionUndo"\)/, + "the toast is the more recent of the two and has to be asked first"); + }); + + test("and the legend covers both undos, because a dealer has no reason to know there are two", () => { + const line = SHORTCUTS.match(/keys: \["U"\][^}]*what: "([^"]+)"/); + assert.ok(line, "the U shortcut has no legend line"); + assert.doesNotMatch(line[1], /last action$/, + "the legend still promises only the bar's pill"); + }); +}); + +describe("one live region, and it is in the markup", () => { + test("the host is the region", () => { + assert.match(PAGE, /id="toastHost"[^>]*aria-live="polite"/); + }); + + test("and a toast does not bring a second one with it", () => { + // A region that arrives already carrying its text has not been watched by + // anything yet, which is the documented reason such a message is never spoken. + // The host is in the markup precisely so that it has been. + assert.ok(!/toast\.setAttribute\("role"/.test(FEEDBACK), + "each toast is its own live region again, nested inside the one that works"); + show({}); + assert.equal(toastOnScreen().getAttribute("role"), null); + }); +});