Answer the press instead of eating it
The card picker greys out a card that is already dealt and hangs the reason off a `title`. Measured on an iPhone with three hands out: seven of the fifty-two were dead, none of them could take focus, and tapping one changed nothing at all — 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. The header's two buttons were the same, on a new dealer's first screen, and Export CSV was greyed out with no explanation written anywhere. `disabled` is the wrong attribute whenever there is something to say: it drops the control out of the tab order, so a screen reader never lands on it to hear the description, and it eats the tap on a touch screen, where the hover that would have shown the `title` does not exist. `aria-disabled` announces the same state and leaves the control reachable, so one capture listener can turn the press into a sentence — in a toast, or inside a sheet that is standing on top of the toast. It paid for itself in the picker's keyboard too. The arrows hunted past dealt cards because a `disabled` button cannot hold focus, and the hunting did the exact thing the grid's no-wrap rule was written to prevent: Right from A-spades over a dealt A-hearts landed on A-diamonds, so the column shifted and the next press of Down walked a different suit. A cell that can hold focus needs no hunting, and the APG asks for that inside a composite widget anyway. The line that answers is one line by measurement, not by taste. Its box is 318px on a Galaxy S8 at 20px of root text; the first wording drew 332px and wrapped, shoving fifty-two targets 21px down mid-tap, and twenty-four capital Ws draw 434px whatever the wording, so the box is what holds. Two things the sweep found on the way. It opened the card picker by un-hiding an overlay whose grid had never been painted, so for its whole life it audited an empty sheet; it now opens it the way a dealer does, with cards out and the answer showing. And the slot buttons above the grid were named to a screen reader by the em dash they draw when empty. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()],
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user