// 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\)/); }); });