Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c1ac222a8 | ||
|
|
784315f2ea |
@@ -163,7 +163,7 @@
|
||||
<template id="seatTemplate">
|
||||
<div class="seat" draggable="true">
|
||||
<div class="seatChairNumber"></div>
|
||||
<button class="dealerChip" title="Make this seat the dealer button">D</button>
|
||||
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
|
||||
<span class="blindBadge sbBadge">SB</span>
|
||||
<span class="blindBadge bbBadge">BB</span>
|
||||
<div class="seatPuckName"></div>
|
||||
|
||||
@@ -591,7 +591,6 @@ button,
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
opacity: 0.32;
|
||||
transition: opacity 0.15s ease;
|
||||
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
|
||||
But tapping it hands the button to that seat and re-posts the blinds, and 22px
|
||||
@@ -622,16 +621,40 @@ button,
|
||||
left: -18px;
|
||||
}
|
||||
|
||||
.dealerChip:focus-visible {
|
||||
opacity: 1;
|
||||
/* A poker table has one dealer button. This one drew nine.
|
||||
|
||||
Every seat carried a puck — the button's, and eight ghosts at 0.32 opacity that
|
||||
were there to be pressed. Measured on a nine-seat table: eight pale discs, all
|
||||
eight showing the letter D, one of them real. Finding the button meant comparing
|
||||
nine identical shapes for the one that's gold, when it should be the only thing
|
||||
of its kind on the felt. The ghosts also sat on top of their neighbours: every
|
||||
remaining "something important is covered" offender on a crowded table was a
|
||||
seat covered by the seat-next-door's ghost puck.
|
||||
|
||||
So a seat that doesn't have the button doesn't draw one. The affordance is still
|
||||
there — hovering a seat fades its slot in, which is a preview of where the button
|
||||
would go — and on touch, where there is no hover, "Give the button" has always
|
||||
been in the seat's long-press menu. Keyboard reaches it by focus.
|
||||
|
||||
pointer-events matters as much as the opacity: an invisible puck that still takes
|
||||
taps would be worse than a visible one. */
|
||||
.dealerChip:not(.active) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.seat:hover .dealerChip {
|
||||
opacity: 1;
|
||||
.seat:hover .dealerChip:not(.active) {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dealerChip:not(.active):focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dealerChip.active {
|
||||
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
|
||||
border-color: #a9782f;
|
||||
|
||||
@@ -174,8 +174,18 @@ const Render = {
|
||||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
dealerChip.classList.toggle("active", index === dealerIndex);
|
||||
const hasButton = index === dealerIndex;
|
||||
dealerChip.classList.toggle("active", hasButton);
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
// Every one of these announced itself as "D", so a screen reader heard a row
|
||||
// of nine identical buttons and couldn't tell which seat held the real one.
|
||||
// The label now says whose it is, or what pressing it would do.
|
||||
const who = (player.name || "").trim() || `seat ${index + 1}`;
|
||||
const chipLabel = hasButton
|
||||
? `${who} has the dealer button`
|
||||
: `Give the dealer button to ${who}`;
|
||||
dealerChip.setAttribute("aria-label", chipLabel);
|
||||
dealerChip.title = chipLabel;
|
||||
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||
// so it goes through the same hook — otherwise this one path would move
|
||||
// the button without moving the blinds that follow it.
|
||||
|
||||
@@ -40,6 +40,22 @@ const Shortcuts = {
|
||||
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||
},
|
||||
|
||||
// Enter and Space are how a keyboard presses the control it is already sitting on.
|
||||
// Claiming them for "advance" didn't just block that press — it ran a different
|
||||
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
|
||||
// the next hand. Space on the same button worked, because "advance" happened to
|
||||
// decline that time and the browser's own activation got through. A control that
|
||||
// does something else every other press is worse than one that does nothing.
|
||||
//
|
||||
// Only these two keys are handed back. The letter shortcuts stay live with a
|
||||
// button focused, because a button does nothing with "f" and the dealer's hand
|
||||
// shouldn't have to leave them.
|
||||
focusedControlOwnsKey(key) {
|
||||
if (key !== "enter" && key !== " ") return false;
|
||||
const el = document.activeElement;
|
||||
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
|
||||
},
|
||||
|
||||
onKey(e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
@@ -47,6 +63,8 @@ const Shortcuts = {
|
||||
const action = this.KEYS[key];
|
||||
if (!action) return;
|
||||
|
||||
if (this.focusedControlOwnsKey(key)) return;
|
||||
|
||||
// Escape is the exception: it should still close the raise pad even if focus
|
||||
// happens to be sitting in its amount field.
|
||||
if (this.isTyping() && !(action === "cancel")) return;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
|
||||
//
|
||||
// Shortcuts talks to `document` directly, so these tests stand a small fake one up
|
||||
// rather than a whole DOM: the only things it asks about are what has focus and
|
||||
// whether an overlay is open.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("path");
|
||||
|
||||
const Shortcuts = require(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
|
||||
|
||||
// A stand-in for a focused element. `matches` answers the same selector list the
|
||||
// real one would, which is the whole of what focusedControlOwnsKey looks at.
|
||||
function element(tagName, { selectors = [], isContentEditable = false } = {}) {
|
||||
return {
|
||||
tagName,
|
||||
isContentEditable,
|
||||
matches: selector => selectors.some(s => selector.includes(s)),
|
||||
};
|
||||
}
|
||||
|
||||
const BUTTON = element("BUTTON", { selectors: ["button"] });
|
||||
const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
|
||||
const LINK = element("A", { selectors: ["a[href]"] });
|
||||
const DIV = element("DIV");
|
||||
const TEXT_FIELD = element("INPUT");
|
||||
|
||||
// Runs one keypress against a fresh Shortcuts and reports which action fired.
|
||||
function press(key, { focused = DIV, overlays = [] } = {}) {
|
||||
const fired = [];
|
||||
let defaultPrevented = false;
|
||||
|
||||
globalThis.document = {
|
||||
activeElement: focused,
|
||||
addEventListener() {},
|
||||
querySelectorAll: () => overlays,
|
||||
};
|
||||
|
||||
const handlers = {};
|
||||
for (const action of Object.values(Shortcuts.KEYS)) {
|
||||
handlers[action] = () => { fired.push(action); return true; };
|
||||
}
|
||||
Shortcuts.handlers = handlers;
|
||||
Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
|
||||
|
||||
return { fired: fired[0] || null, defaultPrevented };
|
||||
}
|
||||
|
||||
test.after(() => { delete globalThis.document; });
|
||||
|
||||
test("Enter on a focused button is left to the button", () => {
|
||||
assert.equal(press("Enter", { focused: BUTTON }).fired, null);
|
||||
});
|
||||
|
||||
test("Space on a focused button is left to the button", () => {
|
||||
assert.equal(press(" ", { focused: BUTTON }).fired, null);
|
||||
});
|
||||
|
||||
test("a key left to the control is not swallowed either", () => {
|
||||
// preventDefault would stop the browser activating the button, which is the
|
||||
// half of the bug that turned "wrong action" into "no action".
|
||||
assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
|
||||
});
|
||||
|
||||
test("summary and links own Enter and Space too", () => {
|
||||
assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
|
||||
assert.equal(press(" ", { focused: LINK }).fired, null);
|
||||
});
|
||||
|
||||
test("Space still advances when nothing pressable has focus", () => {
|
||||
assert.equal(press(" ", { focused: DIV }).fired, "advance");
|
||||
});
|
||||
|
||||
test("Enter still advances when nothing pressable has focus", () => {
|
||||
assert.equal(press("Enter", { focused: DIV }).fired, "advance");
|
||||
});
|
||||
|
||||
test("the letter shortcuts stay live with a button focused", () => {
|
||||
// A button does nothing with "f", so there is no press to hand back — and a
|
||||
// dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
|
||||
assert.equal(press("f", { focused: BUTTON }).fired, "fold");
|
||||
assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
|
||||
assert.equal(press("r", { focused: BUTTON }).fired, "raise");
|
||||
assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
|
||||
assert.equal(press("u", { focused: BUTTON }).fired, "undo");
|
||||
});
|
||||
|
||||
test("typing still silences everything but Escape", () => {
|
||||
assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
|
||||
assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
|
||||
});
|
||||
|
||||
test("an open overlay still owns the keyboard", () => {
|
||||
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
|
||||
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
|
||||
});
|
||||
|
||||
test("a focused control wins before the overlay check, not after", () => {
|
||||
// Both would decline here; the point is that neither path fires an action.
|
||||
assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
|
||||
});
|
||||
|
||||
test("modified keys are never shortcuts", () => {
|
||||
globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
|
||||
const fired = [];
|
||||
Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
|
||||
Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
|
||||
assert.deepEqual(fired, []);
|
||||
});
|
||||
@@ -514,6 +514,34 @@ const LayoutAudit = {
|
||||
return { name: "a seat's centre belongs to that seat", offenders };
|
||||
},
|
||||
|
||||
// A poker table has one dealer button. Nine pucks all showing "D", one of them
|
||||
// gold, turns the most-read marker on the felt into a spot-the-difference — and
|
||||
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
|
||||
// Both halves are checked here: what's drawn, and what it announces itself as.
|
||||
oneDealerButton() {
|
||||
const chips = [...document.querySelectorAll(".dealerChip")];
|
||||
const shown = chips.filter(chip => {
|
||||
const style = getComputedStyle(chip);
|
||||
return style.display !== "none" && style.visibility !== "hidden" &&
|
||||
parseFloat(style.opacity) > 0.05;
|
||||
});
|
||||
const offenders = [];
|
||||
|
||||
if (shown.length > 1) {
|
||||
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
|
||||
}
|
||||
|
||||
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
|
||||
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
|
||||
if (duplicated.length) {
|
||||
offenders.push(
|
||||
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { name: "the felt shows one dealer button", offenders };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
runAll(options = {}) {
|
||||
@@ -527,6 +555,7 @@ const LayoutAudit = {
|
||||
this.horizontalOverflow(),
|
||||
this.fieldFontSize(),
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
];
|
||||
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user