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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user