Let the keyboard into the sheets, and stop dropping it on the floor
Modal answers one question for all six overlays — where focus goes and how Tab is held — and this round it was measured as wrong in three directions. Its list of Tab stops could not see a <summary>. The rulings sheet is twelve of them and one Done button, so the trap decided Done was both ends of the ring: eighty Tab presses, one element ever reached, twelve rulings that could not be opened without a mouse. Adding `details > summary` gives the sheet its thirteen stops back. `:not([tabindex="-1"])` moves onto every line while it is open, because a control taken out of the Tab order still matches `button`. "The first control" was the card picker's Clear: the sheet a dealer opens to write a card down opened on the button that rubs one out. A sheet names its own landing spot now, and on five of six it is the heading — which says which seat, which card, which pot. The confirm dialog keeps a control, because a short question with one irreversible answer should land on the answer that leaves the dealer no worse off. And a sheet that redraws itself was deleting the control that held focus. Choosing a buy-in, picking a card, switching slots, confirming a side pot — all four left focus on the document body. Rebuilt controls carry a name now and are handed back to whoever had them; where there is no coming back, the caller says where to go instead. The card grid was fifty-two Tab stops of the fifty-six in that sheet. It is one now, with the arrows moving inside it, stepping over cards already dealt and stopping at the edges rather than wrapping onto a different rank. 1136 tests, 418 audits clean.
This commit is contained in:
@@ -480,7 +480,7 @@
|
||||
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="addChipsHeader">
|
||||
<h2 id="addChipsTitle"></h2>
|
||||
<h2 id="addChipsTitle" tabindex="-1" data-modal-focus></h2>
|
||||
<span id="addChipsSubtitle"></span>
|
||||
</div>
|
||||
<div id="addChipsQuick"></div>
|
||||
@@ -499,7 +499,7 @@
|
||||
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="rulingsHeader">
|
||||
<h2 id="rulingsTitle">Common rulings</h2>
|
||||
<h2 id="rulingsTitle" tabindex="-1" data-modal-focus>Common rulings</h2>
|
||||
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
|
||||
@@ -696,15 +696,20 @@
|
||||
earns its place twice over here: this sheet is the one that can outgrow the
|
||||
screen, and with the header stuck to the top the way out never scrolls away.
|
||||
|
||||
Third job, the quiet one: Modal focuses a dialog's first control, and the
|
||||
first control used to be Fold. The APG says to keep initial focus off the
|
||||
destructive choice, and a close button at the top is what puts it there
|
||||
without a special case. Escape finds this one rather than the button at the
|
||||
foot, so Escape closes the sheet while Back steps back inside it. *@
|
||||
Third job, the quiet one: Escape finds this close button rather than the one
|
||||
at the foot, so Escape closes the sheet while Back steps back inside it.
|
||||
|
||||
It used to carry a fourth. Modal focused a dialog's first control and the
|
||||
first control here was Fold, so putting a close button above the list was
|
||||
also what moved the landing spot off the destructive choice. That was the
|
||||
right half of the answer — Roselli is clear that a close button is a landing
|
||||
spot for a sentence, not for a sheet long enough to scroll, where a stray
|
||||
Space dismisses the thing the dealer just opened. The heading below names
|
||||
the spot outright now, and it says which seat. *@
|
||||
<div class="seatMenuHeader">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatMenuHeading">
|
||||
<h2 id="seatMenuTitle"></h2>
|
||||
<h2 id="seatMenuTitle" tabindex="-1" data-modal-focus></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button>
|
||||
@@ -718,7 +723,12 @@
|
||||
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="cardPickerHeader">
|
||||
<h2 id="cardPickerTitle"></h2>
|
||||
@* Not the first control any more. Modal used to land on whatever came
|
||||
first in here, and what came first in here was Clear: the sheet a
|
||||
dealer opens to write a card down opened on the button that rubs one
|
||||
out. The heading says which seat and which card, which is the thing
|
||||
worth hearing before any of the fifty-two. *@
|
||||
<h2 id="cardPickerTitle" tabindex="-1" data-modal-focus></h2>
|
||||
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
@@ -745,8 +755,14 @@
|
||||
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
|
||||
<h2 id="confirmTitle"></h2>
|
||||
<p id="confirmMessage"></p>
|
||||
@* The one sheet that lands on a control rather than its heading, and the one
|
||||
where the APG asks for it by name: this is a question with two answers and
|
||||
one of them cannot be taken back, so the dealer starts on the answer that
|
||||
leaves them no worse off. It is short enough to be read at a glance, which is
|
||||
the case where landing on a control is the right thing. Said out loud here
|
||||
rather than left to depend on which button happens to be written first. *@
|
||||
<div class="confirmActions">
|
||||
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmCancelBtn" data-modal-close data-modal-focus class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,7 +771,7 @@
|
||||
<div id="potWizardOverlay" hidden>
|
||||
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||
<div class="potWizardHeader">
|
||||
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||
<h2 id="potWizardTitle" tabindex="-1" data-modal-focus>Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
|
||||
@@ -5837,7 +5837,10 @@ html {
|
||||
.toastClose:focus-visible,
|
||||
.potWizardChoice:focus-visible,
|
||||
.cardSlot:focus-visible,
|
||||
.subPanel summary:focus-visible,
|
||||
/* Every summary in the tool, not only the ones nested inside Table Setup. The rulings
|
||||
are twelve of them and the sidebar's panel names are eight more, and until the Tab
|
||||
order could reach a summary at all there was no way to notice they had no ring. */
|
||||
summary:focus-visible,
|
||||
.seat:focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
@@ -5858,6 +5861,22 @@ textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The heading a sheet lands on. It is only ever focused by script, so `:focus` would
|
||||
put a ring around the title of every sheet every time one opened, on a phone that has
|
||||
no keyboard to show a ring for. `:focus-visible` asks the browser the question that
|
||||
actually matters — did this dealer arrive by keyboard — and answers no for a tap.
|
||||
Offset inward: these headings run to the edge of their sheet and an outward ring
|
||||
would be clipped by it.
|
||||
|
||||
Scoped to the headings on purpose: the confirm dialog names a button as its landing
|
||||
spot, and a button already has a ring above, drawn outward the way every other
|
||||
control in the tool draws one. */
|
||||
h2[data-modal-focus]:focus-visible {
|
||||
outline: 2px solid #7fc4ff;
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
/* ---------- Fields on a tablet ----------
|
||||
The 16px minimum and the 44px touch targets were both written into the phone
|
||||
media query, so every iPad size missed them — measured at 768, 834 and 1024 wide,
|
||||
|
||||
@@ -41,7 +41,17 @@ const AddChips = {
|
||||
];
|
||||
},
|
||||
|
||||
// Choosing a preset redraws the three of them so the chosen one can light up, which
|
||||
// meant the button the dealer had just pressed was deleted while it held focus —
|
||||
// and a deleted element doesn't hand focus on, it drops it on the body. Measured:
|
||||
// Space on "Buy-in" left focus nowhere, inside a modal, with the amount changed and
|
||||
// nothing to say so. See Modal.preservingFocus; the key is the amount, because that
|
||||
// is what makes one of these three buttons itself.
|
||||
render() {
|
||||
Modal.preservingFocus(document.getElementById("addChipsSheet"), () => this.paint());
|
||||
},
|
||||
|
||||
paint() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
document.getElementById("addChipsTitle").textContent =
|
||||
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
|
||||
@@ -59,6 +69,7 @@ const AddChips = {
|
||||
btn.type = "button";
|
||||
btn.className = "addChipsQuickBtn";
|
||||
btn.classList.toggle("selected", Number(this.draft) === option.value);
|
||||
btn.dataset.focusKey = `quick-${option.value}`;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "addChipsQuickLabel";
|
||||
|
||||
@@ -14,6 +14,11 @@ const CardPicker = {
|
||||
{ code: "C", symbol: "♣", red: false },
|
||||
],
|
||||
|
||||
// Which card in the grid the arrows are holding, as a flat index into
|
||||
// RANKS x SUITS. Kept across a redraw so the grid doesn't send the dealer back to
|
||||
// the ace of spades every time they pick something.
|
||||
cursor: 0,
|
||||
|
||||
state: null,
|
||||
hooks: null,
|
||||
// Which set of slots is being filled: a seat's two hole cards, or the five
|
||||
@@ -30,6 +35,7 @@ const CardPicker = {
|
||||
this.target = "seat";
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.cursor = 0;
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
@@ -44,6 +50,7 @@ const CardPicker = {
|
||||
this.seatIndex = null;
|
||||
this.boardLimit = Math.max(1, Math.min(5, limit));
|
||||
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
||||
this.cursor = 0;
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
@@ -138,7 +145,84 @@ const CardPicker = {
|
||||
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
||||
},
|
||||
|
||||
// Every card in the deck, in the order the grid draws them: four suits across,
|
||||
// thirteen ranks down. The arrow keys and the roving tabindex both work in this
|
||||
// order, so it is written once rather than rebuilt by two nested loops that have to
|
||||
// agree.
|
||||
deck() {
|
||||
const cards = [];
|
||||
this.RANKS.forEach(rank => {
|
||||
this.SUITS.forEach(suit => cards.push({ rank, suit, card: `${rank}${suit.code}` }));
|
||||
});
|
||||
return cards;
|
||||
},
|
||||
|
||||
// Where the arrows can actually go. A card already dealt somewhere is a disabled
|
||||
// button, and a disabled button cannot take focus, so stepping has to keep going in
|
||||
// the same direction until it finds one that can — and stay put rather than wrap if
|
||||
// it runs out of grid. Wrapping would put the dealer on a different rank without
|
||||
// them asking to move rank.
|
||||
nextCursor(from, step) {
|
||||
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
|
||||
for (let i = from + step; i >= 0 && i < buttons.length; i += step) {
|
||||
if (!buttons[i].disabled) return i;
|
||||
}
|
||||
return from;
|
||||
},
|
||||
|
||||
// One Tab stop for fifty-two cards.
|
||||
//
|
||||
// Every card used to be its own stop, which made this sheet fifty-six of them:
|
||||
// reaching the seven of clubs meant something like forty presses of the only key
|
||||
// that moves at all without a mouse. The APG's answer for a set of controls like
|
||||
// this is the roving tabindex — one of them is in the Tab order at a time, and the
|
||||
// arrows move between them — so the grid now costs one press to enter and one to
|
||||
// leave, and the arrows bring the row into view on the way, which forty Tab presses
|
||||
// through a scrolling sheet never reliably did.
|
||||
//
|
||||
// No new role is claimed for the container. They are buttons, they announce as
|
||||
// buttons, and the only thing that changed is how far away they are.
|
||||
aimCursor(index, { focus = false } = {}) {
|
||||
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
|
||||
if (!buttons.length) return;
|
||||
const live = buttons[index] && !buttons[index].disabled ? index : this.nextCursor(index, 1);
|
||||
this.cursor = buttons[live] && !buttons[live].disabled ? live : this.nextCursor(index, -1);
|
||||
buttons.forEach((btn, i) => {
|
||||
btn.tabIndex = i === this.cursor && !btn.disabled ? 0 : -1;
|
||||
});
|
||||
if (focus && buttons[this.cursor] && !buttons[this.cursor].disabled) {
|
||||
buttons[this.cursor].focus({ preventScroll: false });
|
||||
}
|
||||
},
|
||||
|
||||
onGridKey(event) {
|
||||
const stride = this.SUITS.length;
|
||||
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
|
||||
if (step !== undefined) {
|
||||
event.preventDefault();
|
||||
this.aimCursor(this.nextCursor(this.cursor, step), { focus: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Home" && event.key !== "End") return;
|
||||
event.preventDefault();
|
||||
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
|
||||
this.aimCursor(event.key === "Home" ? this.nextCursor(-1, 1) : this.nextCursor(count, -1), { focus: true });
|
||||
},
|
||||
|
||||
// A redraw is not a reason to lose your place. Every button in here is thrown away
|
||||
// and rebuilt on each one, so whatever held focus was being deleted out from under
|
||||
// the dealer — see Modal.preservingFocus. The card just picked is the one key that
|
||||
// can't come back, because it is a dealt card now: the slot it went into is the
|
||||
// useful place to be instead.
|
||||
render() {
|
||||
const sheet = document.getElementById("cardPickerSheet");
|
||||
Modal.preservingFocus(sheet, () => this.paint(), () => {
|
||||
const slot = sheet.querySelector("#cardPickerSlots .pickerSlot.active");
|
||||
if (slot) slot.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paint() {
|
||||
const used = this.cardsInUse();
|
||||
const values = this.slotValues();
|
||||
|
||||
@@ -158,6 +242,9 @@ const CardPicker = {
|
||||
btn.textContent = value || "—";
|
||||
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
|
||||
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||
// 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.
|
||||
btn.dataset.focusKey = `slot-${slot}`;
|
||||
btn.addEventListener("click", () => {
|
||||
this.cardIndex = slot;
|
||||
this.render();
|
||||
@@ -167,38 +254,41 @@ const CardPicker = {
|
||||
|
||||
const grid = document.getElementById("cardPickerGrid");
|
||||
grid.innerHTML = "";
|
||||
this.RANKS.forEach(rank => {
|
||||
this.SUITS.forEach(suit => {
|
||||
const card = `${rank}${suit.code}`;
|
||||
const takenBy = used.get(card);
|
||||
this.deck().forEach(({ rank, suit, card }) => {
|
||||
const takenBy = used.get(card);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
if (takenBy) {
|
||||
btn.title = takenBy === "the board"
|
||||
? "Already face up on the board"
|
||||
: `Already dealt to ${takenBy}`;
|
||||
}
|
||||
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
btn.dataset.focusKey = card;
|
||||
if (takenBy) {
|
||||
btn.title = takenBy === "the board"
|
||||
? "Already face up on the board"
|
||||
: `Already dealt to ${takenBy}`;
|
||||
}
|
||||
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
btn.addEventListener("keydown", event => this.onGridKey(event));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
// Hand the Tab order back exactly one of them. Done after the grid is built,
|
||||
// because which card can hold the cursor depends on which are dealt already.
|
||||
this.aimCursor(this.cursor);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,12 +14,24 @@
|
||||
const Modal = {
|
||||
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||
// must not be a Tab stop.
|
||||
//
|
||||
// `details > summary` is here because a summary is focusable without anyone giving
|
||||
// it a tabindex, and leaving it out was not a small omission. The rulings sheet is
|
||||
// twelve summaries and one Done button; this list saw one control in it, so the Tab
|
||||
// trap below decided Done was both the first stop and the last and bounced every
|
||||
// press straight back to it. Measured: eighty Tab presses, one element ever
|
||||
// reached. Twelve rulings, none of them openable without a mouse.
|
||||
//
|
||||
// `:not([tabindex="-1"])` belongs on every line, not only the last. A control taken
|
||||
// out of the Tab order still matches `button` or `input` — and a roving tabindex,
|
||||
// which is what the card grid runs on now, is fifty-one of them at a time.
|
||||
FOCUSABLE: [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'a[href]:not([tabindex="-1"])',
|
||||
'button:not([disabled]):not([tabindex="-1"])',
|
||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
||||
'select:not([disabled]):not([tabindex="-1"])',
|
||||
'textarea:not([disabled]):not([tabindex="-1"])',
|
||||
'details > summary:not([tabindex="-1"])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(","),
|
||||
|
||||
@@ -63,11 +75,22 @@ const Modal = {
|
||||
|
||||
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||
|
||||
// Focus the first real control. A dialog that leaves focus on the button
|
||||
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||
// Content-only sheets get focus on the sheet so the title is announced.
|
||||
// Where the sheet puts the dealer when it opens.
|
||||
//
|
||||
// This used to be "the first control", full stop — one answer to a question six
|
||||
// dialogs answer differently, and on the card picker the first control is Clear.
|
||||
// The sheet for writing a card down opened on the button that rubs one out. The
|
||||
// APG's rule is the opposite of that: where an action is hard to take back, land
|
||||
// on the least destructive thing in the dialog.
|
||||
//
|
||||
// So a sheet names its own landing spot with `data-modal-focus`, and on all five
|
||||
// that name one it is the heading. That is Roselli's advice for a dialog with
|
||||
// more in it than a sentence — give the reader a chance to orient — and here it
|
||||
// has a second use, because these headings say *which*: which seat, which card,
|
||||
// which player's chips. A sheet that names nothing still gets the first control,
|
||||
// and a sheet with no controls at all still gets focus on itself.
|
||||
const focusable = this.focusableIn(overlay);
|
||||
const target = focusable[0];
|
||||
const target = overlay.querySelector("[data-modal-focus]") || focusable[0];
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true });
|
||||
} else if (sheet) {
|
||||
@@ -93,6 +116,39 @@ const Modal = {
|
||||
}
|
||||
},
|
||||
|
||||
// Rebuild part of a sheet without losing the dealer's place in it.
|
||||
//
|
||||
// Emptying the element that currently holds focus does not move focus — it destroys
|
||||
// it, and the browser drops the remains on `<body>`. Measured, on three sheets that
|
||||
// redraw themselves after every tap: choosing a buy-in preset, picking a card,
|
||||
// switching which slot is being filled. All three left focus on the document body,
|
||||
// which inside a modal is nowhere: the next Tab had to be rescued by the wrap above.
|
||||
//
|
||||
// Controls that get rebuilt say who they are with `data-focus-key`, and whoever held
|
||||
// focus is handed back the control with the same name. When that name is gone the
|
||||
// caller is asked instead, because only it knows where the dealer should be next —
|
||||
// the card just picked is a dealt card now, and dealt cards are disabled.
|
||||
preservingFocus(root, rebuild, whenGone) {
|
||||
const active = document.activeElement;
|
||||
const inside = !!(active && root.contains(active));
|
||||
const key = inside ? active.dataset.focusKey : undefined;
|
||||
|
||||
rebuild();
|
||||
|
||||
// Nothing was taken from under the dealer: either they weren't in this sheet, or
|
||||
// the control they were standing on is still standing, and still has them. Only
|
||||
// a rebuild that destroyed the focused element has anything to put right, which
|
||||
// is why Clear — a button in the header that no redraw touches — keeps focus
|
||||
// through a redraw it asked for.
|
||||
if (!inside || active.isConnected) return;
|
||||
|
||||
const again = key === undefined ? null
|
||||
: [...root.querySelectorAll("[data-focus-key]")]
|
||||
.find(el => el.dataset.focusKey === key && !el.disabled);
|
||||
if (again) again.focus({ preventScroll: true });
|
||||
else if (whenGone) whenGone();
|
||||
},
|
||||
|
||||
topmost() {
|
||||
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||
},
|
||||
@@ -130,12 +186,20 @@ const Modal = {
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||
// Where the dealer stands *in the ring*, which is a different question from
|
||||
// where they are on the screen. Three things inside these sheets are on purpose
|
||||
// not in the ring: the heading a sheet lands on, every card in the grid bar the
|
||||
// one the arrows are holding, and — for a moment after a rebuild — nothing at
|
||||
// all, because focus has been dropped on the body. -1 covers all three, and the
|
||||
// wrap has to work from there or the first Shift+Tab off a heading walks out of
|
||||
// the dialog it was supposed to be held inside.
|
||||
const at = focusable.indexOf(document.activeElement);
|
||||
|
||||
if (event.shiftKey && at <= 0) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||
} else if (!event.shiftKey && (at === -1 || at === focusable.length - 1)) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
@@ -108,7 +108,25 @@ const PotWizard = {
|
||||
requestAnimationFrame(update);
|
||||
},
|
||||
|
||||
// Confirming a round replaces the whole step, buttons and all, and the button it
|
||||
// replaces first is the Confirm the dealer just pressed — so on a table with side
|
||||
// pots, paying the main pot dropped focus on the body and the next Tab had to start
|
||||
// over. There is no keeping them where they were here: the control is gone and the
|
||||
// question has changed. The new question is where they should be, so focus lands on
|
||||
// the sentence asking it — "that side pot still leaves $240 unassigned, who's next
|
||||
// best?" — rather than on the button that answers it.
|
||||
renderStepInto() {
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
Modal.preservingFocus(modal, () => this.paintStep(), () => {
|
||||
const asking = modal.querySelector("#potWizardBody .potWizardPrompt")
|
||||
|| document.getElementById("potWizardTitle");
|
||||
if (!asking) return;
|
||||
asking.tabIndex = -1;
|
||||
asking.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paintStep() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
|
||||
@@ -191,7 +191,19 @@ const SeatMenu = {
|
||||
return el;
|
||||
},
|
||||
|
||||
// Stepping into the move picker throws the whole list away, and the first thing it
|
||||
// throws away is the "Move this seat" the dealer just pressed — which left focus on
|
||||
// the body, inside a sheet that had just changed into a different sheet. The
|
||||
// heading is where to be: in move mode it has stopped saying who this is and
|
||||
// started saying what is being asked — "Move Alexandra to…".
|
||||
render() {
|
||||
Modal.preservingFocus(document.getElementById("seatMenuOverlay"), () => this.paint(), () => {
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
if (title) title.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paint() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
const subtitle = document.getElementById("seatMenuSubtitle");
|
||||
|
||||
@@ -43,6 +43,11 @@ const CardPicker = load("cardPicker");
|
||||
// navigator.wakeLock — see wakeLock.test.js.
|
||||
const WakeLock = load("wakeLock");
|
||||
|
||||
// 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.
|
||||
const Modal = load("modal");
|
||||
|
||||
// The seat menu's list — which actions a seat is offered and which group each one
|
||||
// lands in — is decided before anything is drawn, so actionsFor() answers here. Only
|
||||
// render() and open() touch the DOM.
|
||||
@@ -105,6 +110,7 @@ module.exports = {
|
||||
CardPicker,
|
||||
WakeLock,
|
||||
SeatMenu,
|
||||
Modal,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// Where a sheet puts the dealer, and whether they can get anywhere from there.
|
||||
//
|
||||
// Modal exists because six overlays were each missing the same three things, and it
|
||||
// fixed all six at once by answering one question the same way everywhere: focus the
|
||||
// first control, and hold Tab between the first and the last. Measured this round, that
|
||||
// single answer had gone wrong in three separate directions.
|
||||
//
|
||||
// It could not see a <summary>. The rulings sheet is twelve of them and one Done
|
||||
// button, so the trap decided Done was both ends of the ring and bounced every press
|
||||
// back to it: eighty Tab presses, one element ever reached, twelve rulings that could
|
||||
// not be opened without a mouse.
|
||||
//
|
||||
// "The first control" was the card picker's Clear. The sheet a dealer opens to write a
|
||||
// card down opened on the button that rubs one out — the one thing the APG says to keep
|
||||
// a dialog off. And "the first control" is not an answer six dialogs share: Roselli's
|
||||
// rule is that a dialog long enough to scroll should land on its heading so the reader
|
||||
// can orient, and a short question with one irreversible answer should land on the
|
||||
// answer that leaves them no worse off.
|
||||
//
|
||||
// And a sheet that redraws itself was deleting the control that held focus. Choosing a
|
||||
// buy-in, picking a card, switching slots, confirming a side pot: all four left focus on
|
||||
// the document body, which inside a modal is nowhere at all.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Modal, CardPicker } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const src = name => fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
|
||||
|
||||
// The markup of one overlay, from its opening div to the next one's.
|
||||
const OVERLAY_ORDER = ["addChipsOverlay", "rulingsOverlay", "seatMenuOverlay",
|
||||
"cardPickerOverlay", "confirmOverlay", "potWizardOverlay"];
|
||||
const overlay = id => {
|
||||
const at = PAGE.indexOf(`<div id="${id}"`);
|
||||
assert.ok(at >= 0, `there is no ${id}`);
|
||||
const ends = OVERLAY_ORDER
|
||||
.map(other => PAGE.indexOf(`<div id="${other}"`, at + 1))
|
||||
.filter(i => i > at);
|
||||
return PAGE.slice(at, ends.length ? Math.min(...ends) : PAGE.length);
|
||||
};
|
||||
|
||||
// A document small enough to answer the handful of questions these functions ask of
|
||||
// one, and honest about the two that matter: a detached node knows it is detached, and
|
||||
// a disabled button refuses focus.
|
||||
function fakeSheet(specs) {
|
||||
const doc = { activeElement: null };
|
||||
const nodes = specs.map(spec => ({
|
||||
dataset: spec.key === undefined ? {} : { focusKey: spec.key },
|
||||
disabled: !!spec.disabled,
|
||||
isConnected: true,
|
||||
tabIndex: 0,
|
||||
label: spec.key || spec.label,
|
||||
focus() {
|
||||
if (this.disabled) return;
|
||||
doc.activeElement = this;
|
||||
},
|
||||
}));
|
||||
const root = {
|
||||
nodes,
|
||||
contains: el => nodes.includes(el),
|
||||
querySelectorAll: () => nodes.filter(n => n.dataset.focusKey !== undefined),
|
||||
};
|
||||
return { doc, root, nodes };
|
||||
}
|
||||
|
||||
// preservingFocus and the picker's cursor both reach for the global document. They are
|
||||
// given one for the length of a test and it is taken away again afterwards.
|
||||
function withDocument(doc, body) {
|
||||
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
|
||||
const before = globalThis.document;
|
||||
globalThis.document = doc;
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
if (had) globalThis.document = before;
|
||||
else delete globalThis.document;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what counts as somewhere you can Tab to", () => {
|
||||
const selectors = Modal.FOCUSABLE.split(",");
|
||||
|
||||
// The whole of the rulings sheet hung on this one omission.
|
||||
test("a summary is a control, because the browser already treats it as one", () => {
|
||||
assert.ok(selectors.some(s => /details\s*>\s*summary/.test(s)),
|
||||
"a <details> summary is focusable without a tabindex, and this list can't see it");
|
||||
});
|
||||
|
||||
// Left off, a card taken out of the Tab order still matches `button`, and the grid
|
||||
// goes back to being fifty-two stops with a roving tabindex painted on top.
|
||||
test("every line excludes what has been taken out of the Tab order, not just the last", () => {
|
||||
selectors.forEach(selector => {
|
||||
assert.ok(/:not\(\[tabindex="-1"\]\)/.test(selector) || /^\s*\[tabindex\]/.test(selector),
|
||||
`\`${selector.trim()}\` still matches controls that are not Tab stops`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the sheet itself is still off the list", () => {
|
||||
assert.ok(/\[tabindex\]:not\(\[tabindex="-1"\]\)/.test(Modal.FOCUSABLE));
|
||||
});
|
||||
});
|
||||
|
||||
describe("where each sheet lands the dealer", () => {
|
||||
const LANDS_ON_ITS_HEADING = [
|
||||
["cardPickerOverlay", "cardPickerTitle"],
|
||||
["addChipsOverlay", "addChipsTitle"],
|
||||
["rulingsOverlay", "rulingsTitle"],
|
||||
["potWizardOverlay", "potWizardTitle"],
|
||||
["seatMenuOverlay", "seatMenuTitle"],
|
||||
];
|
||||
|
||||
LANDS_ON_ITS_HEADING.forEach(([id, heading]) => {
|
||||
test(`${id} opens on the line that says which`, () => {
|
||||
const markup = overlay(id);
|
||||
const tag = markup.match(new RegExp(`<h2 id="${heading}"[^>]*>`));
|
||||
assert.ok(tag, `${heading} is not an h2 in ${id}`);
|
||||
assert.match(tag[0], /data-modal-focus/);
|
||||
assert.match(tag[0], /tabindex="-1"/,
|
||||
"a heading that is a Tab stop is a Tab stop the dealer has to press past");
|
||||
});
|
||||
});
|
||||
|
||||
// The APG's own example of when to land on a control rather than a heading, and the
|
||||
// reason: one of the two answers cannot be taken back.
|
||||
test("the confirm dialog is the exception, and it lands on Cancel", () => {
|
||||
const markup = overlay("confirmOverlay");
|
||||
const cancel = markup.match(/<button id="confirmCancelBtn"[^>]*>/);
|
||||
assert.ok(cancel);
|
||||
assert.match(cancel[0], /data-modal-focus/);
|
||||
assert.ok(!/<button id="confirmAcceptBtn"[^>]*data-modal-focus/.test(markup),
|
||||
"the dialog lands on the answer that cannot be taken back");
|
||||
});
|
||||
|
||||
test("no sheet names two landing spots, which would be a coin toss", () => {
|
||||
OVERLAY_ORDER.forEach(id => {
|
||||
const found = (overlay(id).match(/data-modal-focus/g) || []).length;
|
||||
assert.equal(found, 1, `${id} names ${found} landing spots`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the card picker no longer opens on Clear", () => {
|
||||
const markup = overlay("cardPickerOverlay");
|
||||
assert.ok(markup.indexOf('id="cardPickerTitle"') < markup.indexOf('id="cardPickerClearBtn"'),
|
||||
"the heading has moved below the button it was there to keep focus off");
|
||||
assert.ok(!/id="cardPickerClearBtn"[^>]*data-modal-focus/.test(markup));
|
||||
});
|
||||
|
||||
// The landing spot is deliberately outside the ring, and a wrap that only knows
|
||||
// "is this the first one" walks straight out of the dialog on the first Shift+Tab.
|
||||
test("the trap wraps from outside the ring as well as from its ends", () => {
|
||||
const modal = src("modal");
|
||||
const at = modal.indexOf("const at = focusable.indexOf(");
|
||||
assert.ok(at >= 0, "the Tab trap no longer asks where the dealer is in the ring");
|
||||
const body = modal.slice(at, modal.indexOf("\n },", at));
|
||||
assert.match(body, /shiftKey && at <= 0/);
|
||||
assert.match(body, /at === -1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a redraw that does not drop the dealer", () => {
|
||||
test("focus comes back to the control with the same name", () => {
|
||||
const { doc, root, nodes } = fakeSheet([
|
||||
{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
|
||||
doc.activeElement = nodes[1];
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
// What a rebuild does: the old buttons go, identical new ones arrive.
|
||||
nodes.forEach(n => { n.isConnected = false; });
|
||||
root.nodes = root.querySelectorAll = null;
|
||||
const rebuilt = fakeSheet([{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
|
||||
rebuilt.nodes.forEach(n => { n.focus = () => { doc.activeElement = n; }; });
|
||||
root.querySelectorAll = () => rebuilt.nodes;
|
||||
root.contains = el => rebuilt.nodes.includes(el);
|
||||
}));
|
||||
|
||||
assert.equal(doc.activeElement.dataset.focusKey, "quick-1000");
|
||||
});
|
||||
|
||||
// The card just picked is a dealt card now, and a dealt card is a disabled button.
|
||||
// There is no coming back to it, so the caller is asked where to go instead.
|
||||
test("a name that comes back disabled hands the decision to the caller", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ key: "AS" }, { key: "KH" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[0].isConnected = false;
|
||||
nodes[0].disabled = true;
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.ok(asked, "focus was left on the body of a modal");
|
||||
});
|
||||
|
||||
test("and a name that is simply gone asks too", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ key: "confirm" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[0].isConnected = false;
|
||||
root.querySelectorAll = () => [];
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.ok(asked);
|
||||
});
|
||||
|
||||
// Clear is in the header. Nothing rebuilds it, so nothing should move it — a redraw
|
||||
// the dealer asked for is not a reason to take them somewhere else.
|
||||
test("a control that survived the redraw is left exactly where it was", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ label: "Clear" }, { key: "AS" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[1].isConnected = false;
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.equal(doc.activeElement, nodes[0]);
|
||||
assert.ok(!asked);
|
||||
});
|
||||
|
||||
test("and a dealer who was never in this sheet is not dragged into it", () => {
|
||||
const { doc, root } = fakeSheet([{ key: "AS" }]);
|
||||
const elsewhere = { dataset: {}, isConnected: true };
|
||||
doc.activeElement = elsewhere;
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {}, () => { asked = true; }));
|
||||
|
||||
assert.equal(doc.activeElement, elsewhere);
|
||||
assert.ok(!asked);
|
||||
});
|
||||
|
||||
// Four sheets redraw themselves on a tap. All four go through this now, and each
|
||||
// one hands over the piece of itself it is about to throw away.
|
||||
[["addChips", "render()", "addChipsSheet"],
|
||||
["cardPicker", "render()", "cardPickerSheet"],
|
||||
["seatMenu", "render()", "seatMenuOverlay"],
|
||||
["potWizard", "renderStepInto()", "potWizardModal"]].forEach(([module, method, root]) => {
|
||||
test(`${module}'s ${method} redraws through it`, () => {
|
||||
const text = src(module);
|
||||
const at = text.indexOf(`\n ${method} {`);
|
||||
assert.ok(at >= 0, `${module} has no ${method}`);
|
||||
const body = text.slice(at, text.indexOf("\n },", at));
|
||||
assert.match(body, /Modal\.preservingFocus\(/,
|
||||
`${module} rebuilds its controls without saying what to do about focus`);
|
||||
assert.ok(body.includes(`"${root}"`),
|
||||
`${module} hands preservingFocus something other than ${root}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fifty-two cards, one Tab stop", () => {
|
||||
test("the deck is the grid's order, written once", () => {
|
||||
const deck = CardPicker.deck();
|
||||
assert.equal(deck.length, 52);
|
||||
assert.equal(new Set(deck.map(c => c.card)).size, 52);
|
||||
// Four suits across, thirteen ranks down — which is what the arrow keys step by.
|
||||
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
|
||||
assert.equal(deck[4].card, "KS");
|
||||
assert.equal(deck[51].card, "2C");
|
||||
});
|
||||
|
||||
const grid = keys => keys.map(key => ({
|
||||
dataset: { focusKey: key.replace("!", "") },
|
||||
disabled: key.includes("!"),
|
||||
tabIndex: 0,
|
||||
focus() {},
|
||||
}));
|
||||
|
||||
const withGrid = (cards, body) => {
|
||||
const nodes = grid(cards);
|
||||
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.
|
||||
withGrid(["8C", "7C!", "6C"], () => {
|
||||
assert.equal(CardPicker.nextCursor(0, 1), 2);
|
||||
});
|
||||
});
|
||||
|
||||
test("and keeps going as far as it has to", () => {
|
||||
withGrid(["AS", "KS!", "QS!", "JS!", "TS"], () => {
|
||||
assert.equal(CardPicker.nextCursor(0, 1), 4);
|
||||
});
|
||||
});
|
||||
|
||||
// Wrapping would answer "move me down one rank" by moving the dealer to a different
|
||||
// suit, which is not what they asked and not where they would look.
|
||||
test("the edge of the grid is the edge, not a way round to the other side", () => {
|
||||
withGrid(["AS", "AH", "AD", "AC"], () => {
|
||||
assert.equal(CardPicker.nextCursor(3, 1), 3);
|
||||
assert.equal(CardPicker.nextCursor(0, -1), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("only one card is in the Tab order at a time", () => {
|
||||
withGrid(["AS", "AH", "AD", "AC"], nodes => {
|
||||
CardPicker.aimCursor(2);
|
||||
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
test("and looks backwards when there is nothing left ahead of it", () => {
|
||||
withGrid(["2S", "2H", "2D!", "2C!"], () => {
|
||||
CardPicker.aimCursor(3);
|
||||
assert.equal(CardPicker.cursor, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("the arrows step by a row of suits, not by a guess", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("onGridKey(event)");
|
||||
assert.ok(at >= 0, "the grid has no arrow keys");
|
||||
const body = picker.slice(at, picker.indexOf("\n },", at));
|
||||
assert.match(body, /const stride = this\.SUITS\.length/);
|
||||
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
|
||||
assert.match(body, /"Home"/);
|
||||
assert.match(body, /"End"/);
|
||||
});
|
||||
|
||||
test("a fresh sheet starts at the top of the deck", () => {
|
||||
const picker = src("cardPicker");
|
||||
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
|
||||
.forEach(signature => {
|
||||
const at = picker.indexOf(signature);
|
||||
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||
assert.match(picker.slice(at, picker.indexOf("this.render();", at)), /this\.cursor = 0;/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("seeing where you are", () => {
|
||||
// Until a summary could be Tabbed to at all, there was no way to notice that
|
||||
// twenty of them had no ring.
|
||||
test("a summary gets the tool's own focus ring, not the browser's", () => {
|
||||
const at = CSS.indexOf("---------- Keyboard focus ----------");
|
||||
assert.ok(at >= 0);
|
||||
const block = CSS.slice(at, CSS.indexOf("outline: 2px solid #7fc4ff", at));
|
||||
assert.match(block, /(^|\n)summary:focus-visible,/);
|
||||
});
|
||||
|
||||
// Only script ever focuses these, so `:focus` would ring the title of every sheet
|
||||
// on a phone that has no keyboard to ring it for.
|
||||
test("the landing heading rings for a keyboard and stays quiet for a thumb", () => {
|
||||
const at = CSS.indexOf('h2[data-modal-focus]:focus-visible {');
|
||||
assert.ok(at >= 0, "the heading a sheet lands on shows nothing when it takes focus");
|
||||
const rule = CSS.slice(at, CSS.indexOf("}", at));
|
||||
assert.match(rule, /outline: 2px solid #7fc4ff/);
|
||||
// These headings run to the edge of their sheet; an outward ring is clipped.
|
||||
assert.match(rule, /outline-offset: -2px/);
|
||||
assert.ok(!/\[data-modal-focus\]:focus\b(?!-)/.test(CSS),
|
||||
"a plain :focus rule would ring the title on every tap");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user