Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122

Open
jheaps wants to merge 27 commits from polish/holdem-seats-one-size into master
12 changed files with 594 additions and 63 deletions
Showing only changes of commit c1d0105d12 - Show all commits
@@ -740,7 +740,10 @@
</div> </div>
<div id="cardPickerOverlay" hidden> <div id="cardPickerOverlay" hidden>
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle"> @* `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. *@
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle" data-why-note="cardPickerNote">
<div class="seatSheetGrip" aria-hidden="true"></div> <div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader"> <div class="cardPickerHeader">
@* Not the first control any more. Modal used to land on whatever came @* Not the first control any more. Modal used to land on whatever came
@@ -753,6 +756,10 @@
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button> <button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div> </div>
<div id="cardPickerSlots"></div> <div id="cardPickerSlots"></div>
@* 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. *@
<p id="cardPickerNote" class="pickerNote" role="status"></p>
<div id="cardPickerGrid"></div> <div id="cardPickerGrid"></div>
</div> </div>
</div> </div>
+55 -8
View File
@@ -298,7 +298,7 @@ button,
landed — and with the grey highlight suppressed above, these have to carry it. 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 Everything presses the same way it would on a real table: down and slightly
darker, no travel time. */ darker, no travel time. */
.holdemBtn:active:not(:disabled), .holdemBtn:active:not(:disabled):not([aria-disabled="true"]),
.linkBtn:active:not(:disabled), .linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled), .seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled), .seatSheetClose:active:not(:disabled),
@@ -313,7 +313,7 @@ button,
} }
.cardSlot:active, .cardSlot:active,
.pickerCard:active:not(:disabled), .pickerCard:active:not(:disabled):not([aria-disabled="true"]),
.dealerChip:active { .dealerChip:active {
transform: translateY(1px) scale(0.97); transform: translateY(1px) scale(0.97);
transition-duration: 0.04s; transition-duration: 0.04s;
@@ -350,14 +350,14 @@ button,
so a new one cannot be added without this being updated. */ so a new one cannot be added without this being updated. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.holdemBtn:hover, .holdemBtn:hover,
.holdemBtn:active:not(:disabled), .holdemBtn:active:not(:disabled):not([aria-disabled="true"]),
.linkBtn:active:not(:disabled), .linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled), .seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled), .seatSheetClose:active:not(:disabled),
.cardSlot:hover, .cardSlot:hover,
.cardSlot:active, .cardSlot:active,
.pickerCard:hover:not(:disabled), .pickerCard:hover:not(:disabled):not([aria-disabled="true"]),
.pickerCard:active:not(:disabled), .pickerCard:active:not(:disabled):not([aria-disabled="true"]),
.dealerChip:active, .dealerChip:active,
.raiseQuickBtn:active:not(:disabled), .raiseQuickBtn:active:not(:disabled),
.actionBtn:active, .actionBtn:active,
@@ -572,7 +572,13 @@ button,
font-size: var(--fs-sm); 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; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
@@ -2636,11 +2642,26 @@ input[type="checkbox"] {
} }
@media (hover: hover) { @media (hover: hover) {
.pickerCard:hover:not(:disabled) { .pickerCard:hover:not(:disabled):not([aria-disabled="true"]) {
transform: translateY(-1px); 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 { .pickerCard.cardRed {
color: #c0392b; color: #c0392b;
} }
@@ -2652,13 +2673,39 @@ input[type="checkbox"] {
/* Already dealt somewhere else at the table — visibly out of play rather than /* Already dealt somewhere else at the table — visibly out of play rather than
silently accepting a duplicate. */ silently accepting a duplicate. */
.pickerCard:disabled { .pickerCard:disabled,
.pickerCard[aria-disabled="true"] {
background: #1d1d1d; background: #1d1d1d;
border-color: #303030; border-color: #303030;
color: var(--line-strong); color: var(--line-strong);
cursor: not-allowed; 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 { .pickerSuit {
font-size: 1.05em; font-size: 1.05em;
line-height: 1; line-height: 1;
@@ -157,17 +157,21 @@ const CardPicker = {
return cards; return cards;
}, },
// Where the arrows can actually go. A card already dealt somewhere is a disabled // Where the arrows can go: one card, in the direction asked for, and nowhere if
// button, and a disabled button cannot take focus, so stepping has to keep going in // that runs off the end of the grid. Wrapping would put the dealer on a different
// the same direction until it finds one that can — and stay put rather than wrap if // rank without them asking to move rank.
// it runs out of 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) { nextCursor(from, step) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")]; const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
for (let i = from + step; i >= 0 && i < buttons.length; i += step) { const to = from + step;
if (!buttons[i].disabled) return i; return to >= 0 && to < count ? to : from;
}
return from;
}, },
// One Tab stop for fifty-two cards. // One Tab stop for fifty-two cards.
@@ -185,14 +189,11 @@ const CardPicker = {
aimCursor(index, { focus = false } = {}) { aimCursor(index, { focus = false } = {}) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")]; const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
if (!buttons.length) return; if (!buttons.length) return;
const live = buttons[index] && !buttons[index].disabled ? index : this.nextCursor(index, 1); this.cursor = Math.max(0, Math.min(buttons.length - 1, index));
this.cursor = buttons[live] && !buttons[live].disabled ? live : this.nextCursor(index, -1);
buttons.forEach((btn, i) => { 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) { if (focus) buttons[this.cursor].focus({ preventScroll: false });
buttons[this.cursor].focus({ preventScroll: false });
}
}, },
onGridKey(event) { onGridKey(event) {
@@ -226,6 +227,13 @@ const CardPicker = {
const used = this.cardsInUse(); const used = this.cardsInUse();
const values = this.slotValues(); 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() document.getElementById("cardPickerTitle").textContent = this.onBoard()
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}` ? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`; : `${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); btn.classList.toggle("active", slot === this.cardIndex);
const value = values[slot] || ""; const value = values[slot] || "";
btn.textContent = value || "—"; 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)); btn.classList.toggle("cardRed", /[HD]$/i.test(value));
// The name this control answers to after a redraw. A slot is always the // 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. // 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.type = "button";
btn.className = "pickerCard"; btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red); btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
btn.dataset.focusKey = card; btn.dataset.focusKey = card;
if (takenBy) { // Named in the sentence rather than left as "this one", because the sentence
btn.title = takenBy === "the board" // is read above the grid and the card that was pressed can be eight rows
? "Already face up on the board" // down it. Short by measurement, not by taste — see .pickerNote.
: `Already dealt to ${takenBy}`; 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) { if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected"); btn.classList.add("selected");
} }
@@ -62,6 +62,10 @@ const Toast = {
clearTimeout(this.timer); clearTimeout(this.timer);
this.timer = null; 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"); const host = document.getElementById("toastHost");
if (host) host.innerHTML = ""; if (host) host.innerHTML = "";
this.publishHeight(); 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) { if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog }; module.exports = { Toast, Dialog, Unavailable };
} }
@@ -5,6 +5,13 @@ document.addEventListener("DOMContentLoaded", () => {
// `hidden` attribute they all already toggle. // `hidden` attribute they all already toggle.
Modal.mount(); 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 scroll is not an edit ----
// //
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse // A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
@@ -260,12 +260,18 @@ const Render = {
// here is only the part about what a control can do — which is still worth // 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. // saying, because a shortcut that cannot work yet should not look ready.
// //
// Disabled rather than hidden: hiding them would move the header the moment // Dimmed rather than hidden: hiding them would move the header the moment the
// the first player is named, and a control that vanishes teaches nothing about // first player is named, and a control that vanishes teaches nothing about what
// what it was for. // 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)); const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
[["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"], [["resolvePotBtn", "Nothing to resolve yet — seat a player first."],
["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]] ["nextHandBtn", "Nothing to move on from — seat a player first."]]
.forEach(([id, why]) => { .forEach(([id, why]) => {
const button = document.getElementById(id); const button = document.getElementById(id);
if (!button) return; if (!button) return;
@@ -274,7 +280,7 @@ const Render = {
if (button.dataset.titleWhenUsable === undefined) { if (button.dataset.titleWhenUsable === undefined) {
button.dataset.titleWhenUsable = button.getAttribute("title") || ""; button.dataset.titleWhenUsable = button.getAttribute("title") || "";
} }
button.disabled = nothingToRunYet; Unavailable.set(button, nothingToRunYet, why);
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable; const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
if (title) button.setAttribute("title", title); if (title) button.setAttribute("title", title);
else button.removeAttribute("title"); else button.removeAttribute("title");
@@ -3221,7 +3227,13 @@ const Render = {
const emptyHint = document.getElementById("handHistoryEmptyHint"); const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || []; const history = this.state.handHistory || [];
emptyHint.hidden = history.length > 0; 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 query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history; const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
+9
View File
@@ -43,6 +43,14 @@ 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
// 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 // 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 // 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. // so little of a document that a stand-in can answer it. See theKeyboardInASheet.
@@ -117,6 +125,7 @@ module.exports = {
SeatMenu, SeatMenu,
Modal, Modal,
QuickSplit, QuickSplit,
Unavailable,
makeTable, makeTable,
chipsOnTable, chipsOnTable,
contributions, contributions,
+19 -8
View File
@@ -32,16 +32,27 @@ const ruleFor = selector => {
return CSS.slice(at, CSS.indexOf("}", at)); 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 atRule = query => {
const at = CSS.indexOf(`@media ${query} {`); const open = `@media ${query} {`;
assert.ok(at >= 0, `there is no \`@media ${query}\` block`); const blocks = [];
let depth = 0; for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
for (let i = CSS.indexOf("{", at); i < CSS.length; i++) { let depth = 0;
if (CSS[i] === "{") depth++; for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1); 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");
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+19 -1
View File
@@ -28,7 +28,25 @@ const VIEWPORTS = [
const OVERLAYS = [ const OVERLAYS = [
["rulings", () => document.getElementById("openRulingsBtn").click()], ["rulings", () => document.getElementById("openRulingsBtn").click()],
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)], ["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, () => {})], ["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
["pot wizard", () => document.getElementById("resolvePotBtn").click()], ["pot wizard", () => document.getElementById("resolvePotBtn").click()],
]; ];
+37 -14
View File
@@ -269,9 +269,12 @@ describe("fifty-two cards, one Tab stop", () => {
assert.equal(deck[51].card, "2C"); 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 => ({ const grid = keys => keys.map(key => ({
dataset: { focusKey: key.replace("!", "") }, dataset: { focusKey: key.replace("!", "") },
disabled: key.includes("!"), getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
tabIndex: 0, tabIndex: 0,
focus() {}, focus() {},
})); }));
@@ -281,16 +284,24 @@ describe("fifty-two cards, one Tab stop", () => {
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes)); return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
}; };
test("a step lands on a card that can actually take focus", () => { test("a step is one card, whether or not that card is already out", () => {
// 7C is dealt, so stepping down the clubs from the eight has to pass over it. // 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"], () => { 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", () => { // The whole reason the skipping had to go. Hunting for the next card that could take
withGrid(["AS", "KS!", "QS!", "JS!", "TS"], () => { // focus did the exact thing the no-wrap rule below was written to prevent: it moved
assert.equal(CardPicker.nextCursor(0, 1), 4); // 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 // Which happens after every pick: the cursor is sitting on a card that has just
// become a dealt card. // become a dealt card. It stays there, because that cell is still a cell and its
test("a cursor left on a dealt card moves to one that can hold it", () => { // 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 => { withGrid(["AS!", "AH", "AD", "AC"], nodes => {
CardPicker.aimCursor(0); CardPicker.aimCursor(0);
assert.equal(CardPicker.cursor, 1); assert.equal(CardPicker.cursor, 0);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, 0, -1, -1]); assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
}); });
}); });
test("and looks backwards when there is nothing left ahead of it", () => { test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
withGrid(["2S", "2H", "2D!", "2C!"], () => { withGrid(["2S!", "2H!", "2D!", "2C!"], nodes => {
CardPicker.aimCursor(3); 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);
}); });
}); });
+279
View File
@@ -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\)/);
});
});
+20
View File
@@ -273,6 +273,25 @@ const LayoutAudit = {
return { name: "tap-highlight", offenders, checked: this.visibleControls().length }; 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. // Text selection must stay ON for anything a dealer types into.
textSelectable() { textSelectable() {
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")] const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
@@ -1583,6 +1602,7 @@ const LayoutAudit = {
this.furnitureBudget(), this.furnitureBudget(),
this.touchAction(), this.touchAction(),
this.tapHighlight(), this.tapHighlight(),
this.dimmedControlsExplain(),
this.textSelectable(), this.textSelectable(),
this.occlusion(options.occlusionSelector), this.occlusion(options.occlusionSelector),
this.collapsedColumns(), this.collapsedColumns(),