Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122
@@ -7,7 +7,20 @@
|
|||||||
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||||
const Toast = {
|
const Toast = {
|
||||||
DEFAULT_DURATION_MS: 8000,
|
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,
|
timer: null,
|
||||||
|
endsAt: 0,
|
||||||
|
remaining: 0,
|
||||||
|
// Where focus was standing when this arrived.
|
||||||
|
returnFocusTo: null,
|
||||||
|
|
||||||
show(message, options = {}) {
|
show(message, options = {}) {
|
||||||
const host = document.getElementById("toastHost");
|
const host = document.getElementById("toastHost");
|
||||||
@@ -17,7 +30,11 @@ const Toast = {
|
|||||||
|
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
toast.className = "toast";
|
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");
|
const text = document.createElement("span");
|
||||||
text.className = "toastText";
|
text.className = "toastText";
|
||||||
@@ -29,6 +46,12 @@ const Toast = {
|
|||||||
action.type = "button";
|
action.type = "button";
|
||||||
action.className = "toastAction";
|
action.className = "toastAction";
|
||||||
action.textContent = options.actionLabel;
|
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", () => {
|
action.addEventListener("click", () => {
|
||||||
this.dismiss();
|
this.dismiss();
|
||||||
options.onAction();
|
options.onAction();
|
||||||
@@ -44,6 +67,27 @@ const Toast = {
|
|||||||
close.addEventListener("click", () => this.dismiss());
|
close.addEventListener("click", () => this.dismiss());
|
||||||
toast.appendChild(close);
|
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);
|
host.appendChild(toast);
|
||||||
// Next frame, so the entrance transition actually runs.
|
// Next frame, so the entrance transition actually runs.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -51,10 +95,51 @@ const Toast = {
|
|||||||
this.publishHeight();
|
this.publishHeight();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.timer = setTimeout(
|
this.arm(options.duration || this.DEFAULT_DURATION_MS);
|
||||||
() => this.dismiss(),
|
},
|
||||||
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() {
|
dismiss() {
|
||||||
@@ -62,12 +147,25 @@ const Toast = {
|
|||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.remaining = 0;
|
||||||
// The same guard publishHeight below already carries, for the same reason: the
|
// 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
|
// bar dismisses a toast on the way into every action it commits, and that path
|
||||||
// is otherwise plain bookkeeping that answers without a document.
|
// is otherwise plain bookkeeping that answers without a document.
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const host = document.getElementById("toastHost");
|
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 <body>, 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();
|
this.publishHeight();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
Toast.show(message, {
|
Toast.show(message, {
|
||||||
actionLabel: "Undo",
|
actionLabel: "Undo",
|
||||||
|
shortcut: "undo",
|
||||||
onAction: () => {
|
onAction: () => {
|
||||||
restoreState(snapshot);
|
restoreState(snapshot);
|
||||||
Toast.show("Undone.", { duration: 2500 });
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
@@ -453,6 +454,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return true;
|
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 =>
|
const quickRaiseButton = label =>
|
||||||
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
||||||
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
.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,
|
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
|
||||||
// streets and showdown without reaching for the trackpad.
|
// streets and showdown without reaching for the trackpad.
|
||||||
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
|
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"),
|
cancel: () => pressInBar(".raiseCancel"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,6 +515,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
Toast.show(message, {
|
Toast.show(message, {
|
||||||
actionLabel: "Undo",
|
actionLabel: "Undo",
|
||||||
|
shortcut: "undo",
|
||||||
onAction: () => {
|
onAction: () => {
|
||||||
restoreState(snapshot);
|
restoreState(snapshot);
|
||||||
Toast.show("Undone.", { duration: 2500 });
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ const Shortcuts = {
|
|||||||
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
||||||
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
||||||
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
|
{ 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: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
|
||||||
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -43,9 +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");
|
||||||
|
|
||||||
// feedback.js exports two namespaces and a third; Unavailable is the one with anything
|
// feedback.js exports two namespaces and a third. Two of them answer here: Unavailable,
|
||||||
// to test, and render.js and cardPicker.js reach for it as a global the way the browser
|
// which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus
|
||||||
// hands it to them. See theUnansweredPress.
|
// 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"));
|
const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
|
||||||
globalThis.Toast = Toast;
|
globalThis.Toast = Toast;
|
||||||
globalThis.Dialog = Dialog;
|
globalThis.Dialog = Dialog;
|
||||||
@@ -125,6 +127,7 @@ module.exports = {
|
|||||||
SeatMenu,
|
SeatMenu,
|
||||||
Modal,
|
Modal,
|
||||||
QuickSplit,
|
QuickSplit,
|
||||||
|
Toast,
|
||||||
Unavailable,
|
Unavailable,
|
||||||
makeTable,
|
makeTable,
|
||||||
chipsOnTable,
|
chipsOnTable,
|
||||||
|
|||||||
@@ -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
|
||||||
|
// <body> — 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 <body>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user