+ @* `data-why-note` is this sheet claiming the job of saying why a card can't be
+ picked. The toast that answers everywhere else is fixed to the bottom of the
+ window, and this sheet stands on top of it. *@
+
@* Not the first control any more. Modal used to land on whatever came
@@ -753,6 +756,10 @@
+ @* Empty until a dealt card is pressed, and holding its line the whole time so
+ that filling it doesn't shove fifty-two cards down the screen under a thumb
+ that is already reaching for one. *@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 4b2d82f..ed8f0c4 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -298,7 +298,7 @@ button,
landed — and with the grey highlight suppressed above, these have to carry it.
Everything presses the same way it would on a real table: down and slightly
darker, no travel time. */
-.holdemBtn:active:not(:disabled),
+.holdemBtn:active:not(:disabled):not([aria-disabled="true"]),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
@@ -313,7 +313,7 @@ button,
}
.cardSlot:active,
-.pickerCard:active:not(:disabled),
+.pickerCard:active:not(:disabled):not([aria-disabled="true"]),
.dealerChip:active {
transform: translateY(1px) scale(0.97);
transition-duration: 0.04s;
@@ -350,14 +350,14 @@ button,
so a new one cannot be added without this being updated. */
@media (prefers-reduced-motion: reduce) {
.holdemBtn:hover,
- .holdemBtn:active:not(:disabled),
+ .holdemBtn:active:not(:disabled):not([aria-disabled="true"]),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
.cardSlot:hover,
.cardSlot:active,
- .pickerCard:hover:not(:disabled),
- .pickerCard:active:not(:disabled),
+ .pickerCard:hover:not(:disabled):not([aria-disabled="true"]),
+ .pickerCard:active:not(:disabled):not([aria-disabled="true"]),
.dealerChip:active,
.raiseQuickBtn:active:not(:disabled),
.actionBtn:active,
@@ -572,7 +572,13 @@ button,
font-size: var(--fs-sm);
}
-.holdemBtn:disabled {
+/* Dimmed is dimmed, however it was said.
+ A control that has a reason to give is marked `aria-disabled` rather than `disabled`
+ so the press can be answered — see Unavailable in feedback.js — and the two have to
+ be indistinguishable on the screen, or the same state would have two looks depending
+ on an implementation detail nobody can see. */
+.holdemBtn:disabled,
+.holdemBtn[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
transform: none;
@@ -2636,11 +2642,26 @@ input[type="checkbox"] {
}
@media (hover: hover) {
- .pickerCard:hover:not(:disabled) {
+ .pickerCard:hover:not(:disabled):not([aria-disabled="true"]) {
transform: translateY(-1px);
}
}
+/* What the paint job owes back, again.
+ `disabled` is a state the OS palette knows: a disabled control is drawn in GrayText
+ without being asked. `aria-disabled` is a word in the accessibility tree and nothing
+ more, so in forced-colors mode a dealt card came out looking exactly like a live one
+ — the whole distinction the grid is built on, gone, on the display setting used by
+ the people most likely to need it. Said in the system keywords, which are the only
+ colours that survive. */
+@media (forced-colors: active) {
+ .pickerCard[aria-disabled="true"],
+ .holdemBtn[aria-disabled="true"] {
+ color: GrayText;
+ border-color: GrayText;
+ }
+}
+
.pickerCard.cardRed {
color: #c0392b;
}
@@ -2652,13 +2673,39 @@ input[type="checkbox"] {
/* Already dealt somewhere else at the table — visibly out of play rather than
silently accepting a duplicate. */
-.pickerCard:disabled {
+.pickerCard:disabled,
+.pickerCard[aria-disabled="true"] {
background: #1d1d1d;
border-color: #303030;
color: var(--line-strong);
cursor: not-allowed;
}
+/* Which card, and who has it. Empty most of the time and holding its line anyway: a
+ message that appears between the slots and the grid would otherwise push fifty-two
+ cards down the screen under a thumb already on its way to one of them.
+
+ One line, and not by choosing short words. Measured on a Galaxy S8 at 20px of root
+ text, which is the narrowest the sweep goes: the note's box is 318px, and the first
+ wording — "A♠ is already dealt to Wolfgang Von Brandenburg." at 24 characters of name,
+ the most a seat will take — drew 332px and wrapped, shoving the grid 21px down mid-tap.
+ Trimming it to "dealt to" bought 39px of margin, and then twenty-four capital Ws drew
+ 434px and took it all back. A name is whatever somebody typed, so no phrasing is safe
+ and the box has to be the thing that holds: nowrap, and the tail is clipped. What gets
+ clipped is the end of a name; what is always readable is the card and where it went,
+ which is the half that answers the press. The whole sentence stays on the card's own
+ `title` for a mouse and for a screen reader. */
+.pickerNote {
+ min-height: 1.35em;
+ margin: 0 0 var(--sp-2);
+ color: var(--gold-bright);
+ font-size: var(--fs-sm);
+ line-height: 1.35;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
.pickerSuit {
font-size: 1.05em;
line-height: 1;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js
index 285c3d7..117bfc0 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js
@@ -157,17 +157,21 @@ const CardPicker = {
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.
+ // Where the arrows can go: one card, in the direction asked for, and nowhere if
+ // that runs off the end of the grid. Wrapping would put the dealer on a different
+ // rank without them asking to move rank.
+ //
+ // This used to hunt past dealt cards, because a `disabled` button cannot take focus
+ // — and hunting did the very thing the no-wrap rule was written to prevent. Right
+ // from A♠ with the ace of hearts dealt landed on A♦, so the column silently became
+ // the one to its right, and the next press of Down walked K♦ instead of K♥. A dealt
+ // card is `aria-disabled` now, so it holds focus like every other cell and the grid
+ // tells the truth about its own shape; the APG asks for exactly that inside a
+ // composite widget, and pressing one is answered rather than ignored.
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;
+ const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
+ const to = from + step;
+ return to >= 0 && to < count ? to : from;
},
// One Tab stop for fifty-two cards.
@@ -185,14 +189,11 @@ const CardPicker = {
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);
+ this.cursor = Math.max(0, Math.min(buttons.length - 1, index));
buttons.forEach((btn, i) => {
- btn.tabIndex = i === this.cursor && !btn.disabled ? 0 : -1;
+ btn.tabIndex = i === this.cursor ? 0 : -1;
});
- if (focus && buttons[this.cursor] && !buttons[this.cursor].disabled) {
- buttons[this.cursor].focus({ preventScroll: false });
- }
+ if (focus) buttons[this.cursor].focus({ preventScroll: false });
},
onGridKey(event) {
@@ -226,6 +227,13 @@ const CardPicker = {
const used = this.cardsInUse();
const values = this.slotValues();
+ // Whatever the line was answering has been answered. Cleared here rather than
+ // on a timer, because a paint is exactly the set of things that make the last
+ // answer stale — a card went in, or the sheet moved to a different slot — and
+ // nothing else in this sheet redraws.
+ const note = document.getElementById("cardPickerNote");
+ if (note) note.textContent = "";
+
document.getElementById("cardPickerTitle").textContent = this.onBoard()
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
@@ -240,7 +248,13 @@ const CardPicker = {
btn.classList.toggle("active", slot === this.cardIndex);
const value = values[slot] || "";
btn.textContent = value || "—";
- if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
+ // What it is called out loud. On the eye it is a card or an em dash, and an
+ // em dash is what a screen reader was being given as the whole name of the
+ // control: "dash, button", twice, above fifty-two more. The slot knows which
+ // slot it is and what is in it, so it says both.
+ const which = this.onBoard() ? this.boardSlotLabel(slot) : `Card ${slot + 1}`;
+ btn.setAttribute("aria-label", value ? `${which}: ${value}` : `${which}: empty`);
+ if (this.onBoard()) btn.title = which;
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.
@@ -261,13 +275,19 @@ const CardPicker = {
btn.type = "button";
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
- btn.disabled = !!takenBy;
btn.dataset.focusKey = card;
- if (takenBy) {
- btn.title = takenBy === "the board"
- ? "Already face up on the board"
- : `Already dealt to ${takenBy}`;
- }
+ // Named in the sentence rather than left as "this one", because the sentence
+ // is read above the grid and the card that was pressed can be eight rows
+ // down it. Short by measurement, not by taste — see .pickerNote.
+ const label = `${rank}${suit.symbol}`;
+ Unavailable.set(btn, !!takenBy, takenBy === "the board"
+ ? `${label} — face up on the board`
+ : `${label} — dealt to ${takenBy}`);
+ // Still the tooltip, and now also the accessible description — `title` only
+ // reaches a screen reader on an element it is allowed to land on, which is
+ // the other half of what `disabled` was costing here.
+ if (takenBy) btn.title = Unavailable.reason(btn);
+ else btn.removeAttribute("title");
if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js
index 423bc8d..3478325 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js
@@ -62,6 +62,10 @@ const Toast = {
clearTimeout(this.timer);
this.timer = null;
}
+ // 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 = "";
this.publishHeight();
@@ -131,6 +135,80 @@ const Dialog = {
},
};
+// A control that is on the screen and cannot act, and the reason it can't.
+//
+// `disabled` is the wrong attribute the moment there is something to say. It takes the
+// control out of the tab order, so a screen reader never lands on it and never hears
+// the `title` hung off it; and it eats the tap on a touch screen, where there is no
+// hover to show a `title` to begin with. Measured on an iPhone with three hands dealt:
+// seven of the card picker's fifty-two cards were dead, none of them could hold focus,
+// and tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM.
+// The tool's answer to "who has the ace of spades?" existed, and was reachable only
+// with a mouse.
+//
+// `aria-disabled` announces the same state and leaves the control reachable, so the
+// press arrives and can be answered instead. Nothing enforces it for you — that is the
+// whole point — so a press is intercepted once, centrally, in `answerPress` below.
+const Unavailable = {
+ // Which control is being told off, if any. A press can land on the text inside a
+ // button rather than the button, so the answer is looked for upwards.
+ from(target) {
+ if (!target || typeof target.closest !== "function") return null;
+ return target.closest('[aria-disabled="true"]');
+ },
+
+ reason(el) {
+ return (el && el.dataset && el.dataset.whyNot) || "";
+ },
+
+ blocked(el) {
+ return !!el && el.getAttribute("aria-disabled") === "true";
+ },
+
+ // Both directions in one call, because every caller works out the condition anyway
+ // and the two halves have to stay in step — a control left marked after the reason
+ // stopped being true is worse than one that was never marked.
+ set(el, blocked, reason) {
+ if (!el) return;
+ if (!blocked) {
+ el.removeAttribute("aria-disabled");
+ delete el.dataset.whyNot;
+ return;
+ }
+ el.setAttribute("aria-disabled", "true");
+ el.dataset.whyNot = reason || "";
+ },
+
+ // Where the answer is written.
+ //
+ // The toast is fixed to the bottom of the window, and a sheet that stands 88% of
+ // the screen tall is sitting on top of it — so a control inside one answers inside
+ // it instead. A sheet opts in by naming its own line: `data-why-note="someId"`.
+ // Anything else falls through to the toast, which is where the table says things.
+ say(el, why) {
+ const host = el && typeof el.closest === "function" ? el.closest("[data-why-note]") : null;
+ const note = host ? document.getElementById(host.dataset.whyNote) : null;
+ if (note) {
+ note.textContent = why;
+ return;
+ }
+ Toast.show(why);
+ },
+
+ // One listener for the whole table, in the capture phase so it arrives before the
+ // control's own handler and can stop it. Registered once, by main.js.
+ listen(root) {
+ root.addEventListener("click", event => {
+ const el = this.from(event.target);
+ const why = this.reason(el);
+ if (!why) return;
+ event.preventDefault();
+ event.stopPropagation();
+ this.say(el, why);
+ }, true);
+ },
+};
+
if (typeof module !== "undefined" && module.exports) {
- module.exports = { Toast, Dialog };
+ module.exports = { Toast, Dialog, Unavailable };
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 9ffa602..c650632 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -5,6 +5,13 @@ document.addEventListener("DOMContentLoaded", () => {
// `hidden` attribute they all already toggle.
Modal.mount();
+ // A press on a control that can't act is answered rather than swallowed. One
+ // listener for the whole table, because the alternative is remembering to guard
+ // every click handler that might one day be attached to a dimmed control — and the
+ // one that gets forgotten is a dead tap with no explanation, which is the bug this
+ // is here to end. See Unavailable in feedback.js.
+ Unavailable.listen(document);
+
// ---- A scroll is not an edit ----
//
// A focused `` is a spinbutton, and a browser lets the mouse
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 6c22a9c..fbcba1a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -260,12 +260,18 @@ const Render = {
// here is only the part about what a control can do — which is still worth
// saying, because a shortcut that cannot work yet should not look ready.
//
- // Disabled rather than hidden: hiding them would move the header the moment
- // the first player is named, and a control that vanishes teaches nothing about
- // what it was for.
+ // Dimmed rather than hidden: hiding them would move the header the moment the
+ // first player is named, and a control that vanishes teaches nothing about what
+ // it was for.
+ //
+ // Dimmed rather than `disabled`, because there is a reason and it was only ever
+ // readable with a mouse. `disabled` took both of these out of the tab order, so
+ // the sentence hung off them never reached a screen reader, and it swallowed the
+ // tap on the device this tool is built for — leaving a dealer's first screen
+ // holding two grey buttons that answered nothing when pressed. See Unavailable.
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
- [["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"],
- ["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]]
+ [["resolvePotBtn", "Nothing to resolve yet — seat a player first."],
+ ["nextHandBtn", "Nothing to move on from — seat a player first."]]
.forEach(([id, why]) => {
const button = document.getElementById(id);
if (!button) return;
@@ -274,7 +280,7 @@ const Render = {
if (button.dataset.titleWhenUsable === undefined) {
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
}
- button.disabled = nothingToRunYet;
+ Unavailable.set(button, nothingToRunYet, why);
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
if (title) button.setAttribute("title", title);
else button.removeAttribute("title");
@@ -3221,7 +3227,13 @@ const Render = {
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
emptyHint.hidden = history.length > 0;
- document.getElementById("exportHistoryBtn").disabled = history.length === 0;
+ // This one was greyed out with no explanation anywhere — not even a tooltip —
+ // so on every device it was a button that did nothing and said nothing about it.
+ Unavailable.set(
+ document.getElementById("exportHistoryBtn"),
+ history.length === 0,
+ "No hands to export yet — the file fills in as the night is played."
+ );
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
diff --git a/tests/holdem/load.js b/tests/holdem/load.js
index cb543fe..d25cbf3 100644
--- a/tests/holdem/load.js
+++ b/tests/holdem/load.js
@@ -43,6 +43,14 @@ 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.
+const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
+globalThis.Toast = Toast;
+globalThis.Dialog = Dialog;
+globalThis.Unavailable = Unavailable;
+
// 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.
@@ -117,6 +125,7 @@ module.exports = {
SeatMenu,
Modal,
QuickSplit,
+ Unavailable,
makeTable,
chipsOnTable,
contributions,
diff --git a/tests/holdem/nativeControls.test.js b/tests/holdem/nativeControls.test.js
index 76c263f..f37e3eb 100644
--- a/tests/holdem/nativeControls.test.js
+++ b/tests/holdem/nativeControls.test.js
@@ -32,16 +32,27 @@ const ruleFor = selector => {
return CSS.slice(at, CSS.indexOf("}", at));
};
-// The body of a whole at-rule block, braces balanced.
+// Every block of a given at-rule, braces balanced, joined.
+//
+// This used to take the first one and stop, which held for exactly as long as there was
+// one of them. A second forced-colors block was added nearer the top of the file for a
+// control that dropped out of the OS palette, and the bill-paying test below started
+// reading that block instead and reporting the pot wizard's answers missing — a false
+// failure about a rule nobody had touched. There is no reason a stylesheet should
+// answer a media query in one place, so this asks about all of them.
const atRule = query => {
- const at = CSS.indexOf(`@media ${query} {`);
- assert.ok(at >= 0, `there is no \`@media ${query}\` block`);
- let depth = 0;
- for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
- if (CSS[i] === "{") depth++;
- else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1);
+ const open = `@media ${query} {`;
+ const blocks = [];
+ for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
+ let depth = 0;
+ for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
+ if (CSS[i] === "{") depth++;
+ else if (CSS[i] === "}" && --depth === 0) { blocks.push(CSS.slice(at, i + 1)); break; }
+ }
+ assert.ok(blocks.length && depth === 0, `\`${open}\` never closes`);
}
- assert.fail(`\`@media ${query}\` never closes`);
+ assert.ok(blocks.length, `there is no \`@media ${query}\` block`);
+ return blocks.join("\n");
};
// ---------------------------------------------------------------------------
diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js
index 6b1b22a..82eefc6 100644
--- a/tests/holdem/runUiAudit.js
+++ b/tests/holdem/runUiAudit.js
@@ -28,7 +28,25 @@ const VIEWPORTS = [
const OVERLAYS = [
["rulings", () => document.getElementById("openRulingsBtn").click()],
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
- ["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
+ // Opened the way a dealer opens it, with cards already out. This used to un-hide the
+ // overlay and nothing else, so the grid inside it was whatever `paint()` had last
+ // drawn — which on a freshly loaded page is nothing at all. For the whole life of
+ // this sweep the card picker was audited as an empty sheet: no slots, no fifty-two
+ // targets, no dealt cards, none of the three things it is made of.
+ ["card picker", () => {
+ const state = HoldemState.load();
+ const playing = state.seats.filter(seat => seat.inHand);
+ if (playing[0]) playing[0].cards = ["AS", "KH"];
+ if (playing[1]) playing[1].cards = ["QD", "QC"];
+ state.board = ["7C", "2H", "TS", "", ""];
+ const empty = playing[2] || playing[0];
+ CardPicker.open(state, { onPick: () => {}, onDone: () => {} }, state.seats.indexOf(empty), 0);
+ // And with its answer showing. The line that says where a dealt card went is the
+ // one piece of this sheet that is empty until something is pressed, so pressing
+ // one is the only way the sweep ever sees it holding text.
+ const out = document.querySelector('#cardPickerGrid .pickerCard[aria-disabled="true"]');
+ if (out) out.click();
+ }],
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
];
diff --git a/tests/holdem/theKeyboardInASheet.test.js b/tests/holdem/theKeyboardInASheet.test.js
index d6d5b3b..ca46083 100644
--- a/tests/holdem/theKeyboardInASheet.test.js
+++ b/tests/holdem/theKeyboardInASheet.test.js
@@ -269,9 +269,12 @@ describe("fifty-two cards, one Tab stop", () => {
assert.equal(deck[51].card, "2C");
});
+ // A dealt card is `aria-disabled` rather than `disabled`, so it holds focus like any
+ // other cell — which is what lets the arrows tell the truth about the grid, and what
+ // lets pressing one be answered instead of ignored. See theUnansweredPress.
const grid = keys => keys.map(key => ({
dataset: { focusKey: key.replace("!", "") },
- disabled: key.includes("!"),
+ getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
tabIndex: 0,
focus() {},
}));
@@ -281,16 +284,24 @@ describe("fifty-two cards, one Tab stop", () => {
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.
+ test("a step is one card, whether or not that card is already out", () => {
+ // 7C is dealt. Stepping down the clubs from the eight lands on it and says so,
+ // rather than quietly arriving at the six.
withGrid(["8C", "7C!", "6C"], () => {
- assert.equal(CardPicker.nextCursor(0, 1), 2);
+ assert.equal(CardPicker.nextCursor(0, 1), 1);
});
});
- test("and keeps going as far as it has to", () => {
- withGrid(["AS", "KS!", "QS!", "JS!", "TS"], () => {
- assert.equal(CardPicker.nextCursor(0, 1), 4);
+ // The whole reason the skipping had to go. Hunting for the next card that could take
+ // focus did the exact thing the no-wrap rule below was written to prevent: it moved
+ // the dealer sideways without being asked. Right from the ace of spades over a dealt
+ // ace of hearts landed on the ace of diamonds, so the column silently became the one
+ // to its right, and the next press of Down walked the king of diamonds.
+ test("and it never slides the dealer into a column they didn't ask for", () => {
+ withGrid(["AS", "AH!", "AD", "AC", "KS", "KH", "KD", "KC"], () => {
+ const afterRight = CardPicker.nextCursor(0, 1);
+ assert.equal(afterRight, 1, "a dealt ace of hearts is still the cell to the right of the ace of spades");
+ assert.equal(CardPicker.nextCursor(afterRight, 4), 5, "and down from it is the king of the same suit");
});
});
@@ -311,19 +322,31 @@ describe("fifty-two cards, one Tab stop", () => {
});
// 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", () => {
+ // become a dealt card. It stays there, because that cell is still a cell and its
+ // neighbours are still where they were a moment ago.
+ test("a cursor left on a dealt card stays on 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]);
+ assert.equal(CardPicker.cursor, 0);
+ assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
});
});
- test("and looks backwards when there is nothing left ahead of it", () => {
- withGrid(["2S", "2H", "2D!", "2C!"], () => {
+ test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
+ withGrid(["2S!", "2H!", "2D!", "2C!"], nodes => {
CardPicker.aimCursor(3);
- assert.equal(CardPicker.cursor, 1);
+ assert.equal(CardPicker.cursor, 3);
+ assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
+ });
+ });
+
+ // An index from outside the grid is clamped rather than wrapped, which is what
+ // Home and End lean on: they ask for one card past either end and take what they
+ // are given.
+ test("Home and End ask past the ends and land on them", () => {
+ withGrid(["AS", "AH", "AD", "AC"], () => {
+ assert.equal(CardPicker.nextCursor(-1, 1), 0);
+ assert.equal(CardPicker.nextCursor(4, -1), 3);
});
});
diff --git a/tests/holdem/theUnansweredPress.test.js b/tests/holdem/theUnansweredPress.test.js
new file mode 100644
index 0000000..f2fce35
--- /dev/null
+++ b/tests/holdem/theUnansweredPress.test.js
@@ -0,0 +1,279 @@
+// A control that can't act, pressed on the device this tool is for.
+//
+// Measured on an iPhone, three hands dealt, the card picker open on an empty seat:
+// seven of the fifty-two cards were greyed out, none of them could take focus, and
+// tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM. The
+// tool did have an answer to "who has the ace of spades?"; it was a `title`, and a
+// `title` needs a mouse. The same held in the header, where a new dealer's very first
+// screen is two dimmed buttons, and on Export CSV, which was greyed out with no
+// explanation written anywhere at all.
+//
+// `disabled` is the wrong attribute whenever there is something to say. It takes the
+// control out of the tab order, so a screen reader never lands on it and never hears
+// the description; and it eats the press on a touch screen, where the hover that would
+// have shown the `title` does not exist. NN/g's rule for the case is explicit — on a
+// touch interface, a press on a disabled control should raise the explanation — and
+// WCAG 3.3.1 wants the instruction reachable at all. `aria-disabled` announces the same
+// state and leaves the control reachable, so the press arrives and is answered.
+//
+// It also fixed something nobody had noticed. The picker's arrow keys hunted past dealt
+// cards, because a `disabled` button cannot hold focus — and hunting did the exact thing
+// the grid's no-wrap rule was written to prevent: Right from the ace of spades over a
+// dealt ace of hearts landed on the ace of diamonds, so the column silently shifted and
+// the next press of Down walked a different suit. A cell that can hold focus needs no
+// hunting, so that code is gone and the grid tells the truth about its own shape.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { Unavailable } = require("./load");
+
+const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
+const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
+const MARKUP = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
+const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
+
+const ruleFor = selector => {
+ const at = CSS.indexOf(`\n${selector} {`);
+ assert.ok(at >= 0, `there is no \`${selector}\` rule`);
+ return CSS.slice(at, CSS.indexOf("}", at));
+};
+
+// Enough of an element for a module that only ever reads three attributes off one.
+const element = (attrs = {}) => ({
+ dataset: {},
+ attrs: { ...attrs },
+ setAttribute(name, value) { this.attrs[name] = String(value); },
+ getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; },
+ removeAttribute(name) { delete this.attrs[name]; },
+});
+
+// ---------------------------------------------------------------------------
+
+describe("marking a control that cannot act", () => {
+ test("it says so in the tree, and carries the reason with it", () => {
+ const btn = element();
+ Unavailable.set(btn, true, "Nothing to resolve yet — seat a player first.");
+ assert.equal(btn.getAttribute("aria-disabled"), "true");
+ assert.equal(Unavailable.reason(btn), "Nothing to resolve yet — seat a player first.");
+ assert.ok(Unavailable.blocked(btn));
+ });
+
+ // The state and the reason are set and cleared in one call on purpose. A control
+ // left marked after the reason stopped being true is worse than one never marked:
+ // it is a live control that refuses to work and explains why in the past tense.
+ test("and both halves come off together", () => {
+ const btn = element();
+ Unavailable.set(btn, true, "Nothing to resolve yet.");
+ Unavailable.set(btn, false, "Nothing to resolve yet.");
+ assert.equal(btn.getAttribute("aria-disabled"), null);
+ assert.equal(Unavailable.reason(btn), "");
+ assert.ok(!Unavailable.blocked(btn));
+ });
+
+ test("a control nobody marked is not blocked and has nothing to say", () => {
+ assert.ok(!Unavailable.blocked(element()));
+ assert.equal(Unavailable.reason(element()), "");
+ assert.equal(Unavailable.reason(null), "");
+ assert.ok(!Unavailable.blocked(null));
+ });
+
+ // `aria-disabled="false"` is a real thing to write and it means enabled. Reading the
+ // attribute's presence rather than its value would have got that backwards.
+ test("and one marked false is enabled, which is what the attribute means", () => {
+ const btn = element({ "aria-disabled": "false" });
+ assert.ok(!Unavailable.blocked(btn));
+ });
+});
+
+describe("what happens when it is pressed", () => {
+ // The press can land on a span inside the button rather than the button, which is
+ // most of what the picker's cards are made of.
+ test("the answer is looked for upwards from whatever was hit", () => {
+ const btn = element();
+ Unavailable.set(btn, true, "A♠ — dealt to Maya");
+ const rank = { closest: selector => (selector === '[aria-disabled="true"]' ? btn : null) };
+ assert.equal(Unavailable.reason(Unavailable.from(rank)), "A♠ — dealt to Maya");
+ });
+
+ test("a press on nothing in particular finds nothing", () => {
+ assert.equal(Unavailable.from(null), null);
+ assert.equal(Unavailable.from({}), null);
+ });
+
+ // Nothing about `aria-disabled` stops a click reaching its own handler — that is the
+ // trade for staying reachable. One listener does the stopping, in the capture phase,
+ // so a control's own handler never runs and never has to remember to check.
+ test("the press is stopped before the control's own handler sees it", () => {
+ const listener = src("feedback").slice(src("feedback").indexOf("listen(root) {"));
+ assert.match(listener, /addEventListener\("click"[\s\S]*?\}, true\)/,
+ "the listener is not in the capture phase, so it cannot stop anything");
+ assert.match(listener, /event\.preventDefault\(\)/);
+ assert.match(listener, /event\.stopPropagation\(\)/);
+ });
+
+ test("and it is wired once, for the whole table", () => {
+ const main = src("main");
+ assert.match(main, /Unavailable\.listen\(document\)/);
+ assert.equal((main.match(/Unavailable\.listen\(/g) || []).length, 1);
+ });
+
+ // A control with nothing to say is not a control this is about — the seat sheet's
+ // own greyed-out fields, say. Swallowing its press would be a new dead end.
+ test("a marked control with no reason is left entirely alone", () => {
+ const btn = element();
+ Unavailable.set(btn, true, "");
+ assert.equal(Unavailable.reason(btn), "");
+ });
+});
+
+describe("where the answer is written", () => {
+ // The toast is fixed to the bottom of the window at z-index 80; the card picker's
+ // scrim is 95 and its sheet stands 88vh tall. A toast raised for a control inside
+ // that sheet would be behind it, or over the cards if it were not.
+ test("a sheet that covers the toast says it itself", () => {
+ assert.match(MARKUP, /id="cardPickerSheet"[^>]*data-why-note="cardPickerNote"/);
+ assert.match(MARKUP, /id="cardPickerNote"[^>]*role="status"/);
+ });
+
+ test("and everything else falls through to the toast", () => {
+ const say = src("feedback").slice(src("feedback").indexOf("say(el, why) {"));
+ assert.match(say, /closest\("\[data-why-note\]"\)/);
+ assert.match(say, /Toast\.show\(why\)/);
+ });
+
+ // Cleared on the next paint rather than on a timer: a paint is exactly the set of
+ // things that make the last answer stale, and nothing else in the sheet redraws.
+ test("the line is cleared by the next thing that changes the sheet", () => {
+ const picker = src("cardPicker");
+ const at = picker.indexOf("paint() {");
+ assert.ok(at >= 0);
+ const opening = picker.slice(at, picker.indexOf("cardPickerTitle", at));
+ assert.match(opening, /getElementById\("cardPickerNote"\)/);
+ assert.match(opening, /note\.textContent = ""/);
+ });
+});
+
+describe("the line holds its place", () => {
+ // Filling it must not move the grid. It sits between the slots and the cards, so a
+ // line that appeared from nothing would shove fifty-two targets down the screen
+ // under a thumb already travelling towards one of them.
+ test("it is one line tall whether or not it is saying anything", () => {
+ const rule = ruleFor(".pickerNote");
+ assert.match(rule, /min-height:\s*1\.35em/);
+ assert.match(rule, /line-height:\s*1\.35/);
+ });
+
+ // Measured: the note's box is 318px on a Galaxy S8 at 20px of root text, and a name
+ // is whatever somebody typed. Twenty-four capital Ws draw 434px in it. No wording
+ // is safe, so the box is what holds.
+ test("and it cannot become two, whatever the name is", () => {
+ const rule = ruleFor(".pickerNote");
+ assert.match(rule, /white-space:\s*nowrap/);
+ assert.match(rule, /overflow:\s*hidden/);
+ assert.match(rule, /text-overflow:\s*ellipsis/);
+ });
+
+ test("what it measured is written beside the rule", () => {
+ const at = CSS.indexOf("/* Which card, and who has it.");
+ assert.ok(at >= 0, "the rule carries no note saying why");
+ const note = CSS.slice(at, CSS.indexOf("*/", at));
+ assert.match(note, /318px/);
+ assert.match(note, /434px/);
+ });
+
+ // Which is the half that survives the clipping, and the half that does not.
+ test("the card and where it went come before the name", () => {
+ const picker = src("cardPicker");
+ assert.match(picker, /\$\{label\} — face up on the board/);
+ assert.match(picker, /\$\{label\} — dealt to \$\{takenBy\}/);
+ });
+});
+
+describe("dimmed looks dimmed, however it was said", () => {
+ test("a button marked either way is painted the same", () => {
+ const at = CSS.indexOf("\n.holdemBtn:disabled,");
+ assert.ok(at >= 0, "the two states are painted by separate rules, so they can drift apart");
+ assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.holdemBtn\[aria-disabled="true"\]/);
+ });
+
+ test("and so is a card", () => {
+ const at = CSS.indexOf("\n.pickerCard:disabled,");
+ assert.ok(at >= 0);
+ assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.pickerCard\[aria-disabled="true"\]/);
+ });
+
+ // A press animation on a control that will not act is a lie told in 40ms.
+ test("neither one presses down under a finger that can't use it", () => {
+ [".holdemBtn:active", ".pickerCard:active", ".pickerCard:hover"].forEach(base => {
+ const found = CSS.includes(`${base}:not(:disabled):not([aria-disabled="true"])`);
+ assert.ok(found, `\`${base}\` still animates for a control marked aria-disabled`);
+ });
+ });
+
+ // `disabled` is a state the OS palette knows and draws in GrayText by itself.
+ // `aria-disabled` is a word in the accessibility tree and nothing else, so in
+ // forced-colors mode a dealt card came out looking exactly like a live one.
+ test("and the OS is told about it too, since it only knows the other one", () => {
+ // Whichever of the file's forced-colors blocks it landed in — there is more than
+ // one of them, and the stylesheet is free to answer a query wherever it likes.
+ const open = "@media (forced-colors: active) {";
+ const blocks = [];
+ for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
+ blocks.push(CSS.slice(at, CSS.indexOf("\n}", at)));
+ }
+ assert.ok(blocks.length, "nothing in the file answers forced-colors at all");
+ const answering = blocks.filter(b => /\[aria-disabled="true"\]/.test(b));
+ assert.equal(answering.length, 1, "the dimmed state has no forced-colors answer");
+ assert.match(answering[0], /\.pickerCard\[aria-disabled="true"\]/);
+ assert.match(answering[0], /\.holdemBtn\[aria-disabled="true"\]/);
+ assert.match(answering[0], /color:\s*GrayText/);
+ });
+});
+
+describe("the three controls that were dead ends", () => {
+ test("the header's two say why, and are reachable enough to say it", () => {
+ const render = src("render");
+ const at = render.indexOf("renderHeaderActions() {");
+ const body = render.slice(at, render.indexOf("\n },", at));
+ assert.match(body, /Unavailable\.set\(button, nothingToRunYet, why\)/);
+ assert.ok(!/button\.disabled = /.test(body), "one of them is still natively disabled");
+ assert.match(body, /Nothing to resolve yet/);
+ assert.match(body, /Nothing to move on from/);
+ });
+
+ test("Export CSV has a reason at all, which it never did", () => {
+ const render = src("render");
+ const at = render.indexOf('getElementById("exportHistoryBtn")');
+ assert.ok(at >= 0);
+ const around = render.slice(at - 200, at + 260);
+ assert.match(around, /Unavailable\.set\(/);
+ assert.match(around, /No hands to export yet/);
+ });
+
+ // Found the same afternoon, in the same sheet, by running the whole audit over an
+ // open overlay for the first time: the two slot buttons above the grid are drawn as
+ // a card or an em dash, and the em dash was the entire name a screen reader was
+ // given. "Dash, button", twice, on the sheet where a dealer writes a card down.
+ test("and the slots above it are called something", () => {
+ const picker = src("cardPicker");
+ const at = picker.indexOf("btn.className = \"pickerSlot\"");
+ const body = picker.slice(at, picker.indexOf("slots.appendChild(btn)", at));
+ assert.match(body, /this\.boardSlotLabel\(slot\) : `Card \$\{slot \+ 1\}`/,
+ "a seat's slots still have no name of their own");
+ assert.match(body, /setAttribute\("aria-label", value \? .* : .*empty.*\)/,
+ "an empty slot does not say that it is empty");
+ });
+
+ test("and a dealt card names itself rather than saying \"this one\"", () => {
+ const picker = src("cardPicker");
+ const at = picker.indexOf("const takenBy = used.get(card)");
+ const body = picker.slice(at, picker.indexOf("grid.appendChild(btn)", at));
+ assert.match(body, /Unavailable\.set\(btn, !!takenBy/);
+ assert.ok(!/btn\.disabled = /.test(body), "the cards are still natively disabled");
+ // The `title` is the same sentence, and now reaches a screen reader because the
+ // element it hangs off can be landed on.
+ assert.match(body, /btn\.title = Unavailable\.reason\(btn\)/);
+ });
+});
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 63d0ace..2dfa816 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -273,6 +273,25 @@ const LayoutAudit = {
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
},
+ // A control that is on the screen and cannot act has to say why, on the device it is
+ // being used on. `disabled` cannot: it drops the control out of the tab order, so a
+ // screen reader never lands on it to hear the `title`, and it eats the tap on a touch
+ // screen, where there is no hover to show a `title` anyway. That left the card picker
+ // answering "who has the ace of spades?" only to a mouse, and Export CSV answering
+ // nobody at all.
+ //
+ // So a control that is dimmed is either marked `aria-disabled` with a reason on it —
+ // which the one listener in main.js turns into a sentence when it is pressed — or it
+ // is `disabled` and there is genuinely nothing to say, which is the seat sheet's
+ // fields and a stepper sitting on its own end. Having a `title` and being `disabled`
+ // is the combination that cannot work, and it is the one this looks for.
+ dimmedControlsExplain() {
+ const offenders = this.visibleControls()
+ .filter(el => el.disabled && (el.getAttribute("title") || "").trim())
+ .map(el => `${this.describe(el)} :: disabled, and its only explanation is a title`);
+ return { name: "a control that can't act says why where it can be read", offenders };
+ },
+
// Text selection must stay ON for anything a dealer types into.
textSelectable() {
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
@@ -1583,6 +1602,7 @@ const LayoutAudit = {
this.furnitureBudget(),
this.touchAction(),
this.tapHighlight(),
+ this.dimmedControlsExplain(),
this.textSelectable(),
this.occlusion(options.occlusionSelector),
this.collapsedColumns(),