Stop the fixed bar and the toast sitting on the keyboard's focus
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden
by something the page has put on top of it. The bar is fixed over the
bottom and is not in the flow, so the browser does not know it is there:
Tab to a control near the bottom and it is scrolled flush against the
edge of the window, under the bar, and the browser considers it done.
Tabbing the whole page measured 16 stops resting completely covered on a
390x844 phone and 27 more partly, plus four under a toast on the desktop.
It is now 0 and 0 on desktop, phone and a phone on its side — clean
against 2.4.12 (AAA) as well, which allows no overlap at all.
Three things were wrong and they needed different answers.
scroll-padding-bottom on the scroll container, sized from the bar's own
published height and the toast's, handles the case where the browser has
to scroll to reach the control. That is half of it, and the half that
looks like all of it: a control the browser already considers visible is
one it will not scroll to, and a fixed overlay on top does not enter into
that decision. Measured — tabbing to Common rulings with a toast up left
it 100% covered and scrollIntoView({block:"nearest"}) moved nothing. So a
focusin handler measures the overlap and scrolls by it, and when the page
has no room left (26px against 94px wanted) the toast gives way instead.
Sizing the page padding for the toast would have fixed that by making the
page grow and shrink 64px whenever anything was announced.
The page's room to scroll past the bar was 9rem on the sidebar alone,
which left the table's column — most of a desktop — with none. It is now
on the whole app and taken from the bar's real height, so it is also 0 in
the layout where the bar is a rail down the side.
And the seat editor's bottom sheet was put away with a transform, which
moves a thing without putting it away: its Done button sat at y=850 on an
844px screen, focusable, announced, and impossible to scroll to, because
a fixed element does not move when the page does. visibility:hidden puts
it away, transitioned so the slide-out still runs.
uiAudit.js gained focusHasRoom() and focusablesReachable() for the two
static halves. Audit unchanged at 2 failing configurations and 3
offenders, both pre-existing. 805 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -2692,9 +2692,20 @@ button,
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The bar is fixed over the page, so the sidebar needs room to scroll past it. */
|
||||
body.hasActionBar #holdemSidebar {
|
||||
padding-bottom: 9rem;
|
||||
/* The bar is fixed over the page, so the page needs room to scroll past it.
|
||||
|
||||
This was 9rem on the sidebar alone, which gave the column of panels somewhere to go
|
||||
and left the table's column with nothing. That is most of the page on a desktop, and
|
||||
it is where the two links under the felt live: measured at 1280x900, the page had 36px
|
||||
of scroll in it against the 94px needed to lift Common rulings out from under a toast,
|
||||
so the focus handler asked for a scroll the page could not make.
|
||||
|
||||
On the whole app rather than one column, and taken from the bar's published height
|
||||
rather than guessed, so it is right in both layouts — 0 when the bar is a rail down
|
||||
the side, which takes no height at all. The fallback keeps the old number for the
|
||||
moment before the first measurement lands. */
|
||||
body.hasActionBar #holdemApp {
|
||||
padding-bottom: calc(var(--actionBarHeight, 9rem) + var(--sp-4));
|
||||
}
|
||||
|
||||
/* ---------- The bar lines up with the table ----------
|
||||
@@ -4086,12 +4097,25 @@ body.hasActionBar #toastHost {
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
|
||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||
transform: translateY(105%);
|
||||
transition: transform 0.25s ease;
|
||||
/* A transform moves a thing; it does not put it away. Parked off the bottom of
|
||||
the window the sheet was still in the tab order and still in the accessibility
|
||||
tree, so tabbing through a phone reached its Done button at y=850 on an 844px
|
||||
screen — focused, announced, and impossible to see or scroll to, because a
|
||||
fixed element does not move when the page does.
|
||||
|
||||
visibility does put it away: hidden takes the subtree out of the focus order
|
||||
and off the tree, and unlike display it can be transitioned. The delay is what
|
||||
keeps the slide-out visible — visibility flips at the end of the 0.25s on the
|
||||
way out, and immediately on the way in. */
|
||||
visibility: hidden;
|
||||
transition: transform 0.25s ease, visibility 0s linear 0.25s;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body.seatSheetOpen #seatEditorPanel {
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition: transform 0.25s ease, visibility 0s;
|
||||
}
|
||||
|
||||
.seatSheetGrip {
|
||||
@@ -4707,6 +4731,40 @@ body.hasActionBar #toastHost {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Focus is brought to rest where it can be seen ----------
|
||||
|
||||
The ring below was the easy half. The hard half is that the bar is fixed over the
|
||||
bottom of the page, so it is not in the flow and the browser does not know it is
|
||||
there: Tab to a control near the bottom and the browser scrolls it flush against the
|
||||
edge of the window — under the bar — and considers the job done. Measured by tabbing
|
||||
the whole page on a 390x844 phone: 16 stops came to rest completely covered and 27
|
||||
more partly. That is WCAG 2.2 SC 2.4.11, which asks that a focused control not be
|
||||
entirely hidden by something the page has put on top of it.
|
||||
|
||||
scroll-padding is the fix, and it goes on the scroll container rather than on the
|
||||
controls: it tells the browser to treat the bottom of the viewport as not there when
|
||||
it scrolls something into view. The number comes from the bar, which already
|
||||
publishes its own height for the toast and the felt, so it cannot drift from the
|
||||
bar's real size the way a guess would — and it is already 0 when the bar is hidden or
|
||||
has become a rail down the side, which is the case scroll-padding-right answers.
|
||||
|
||||
The toast publishes its height into the same sum, because when the browser does
|
||||
scroll it has to clear both.
|
||||
|
||||
This is only half of it, though, and the half that is easy to mistake for all of it.
|
||||
scroll-padding only applies when the browser scrolls at all, and a control already
|
||||
inside the viewport is one it sees no reason to move — a fixed overlay on top of it
|
||||
does not enter into the decision. Measured on a 1280x900 desktop: tabbing to Common
|
||||
rulings while a toast was up left it completely covered, and this rule did nothing
|
||||
about it, because nothing scrolled. The other half is the focusin handler in main.js,
|
||||
which measures the overlap and scrolls by it. */
|
||||
html {
|
||||
scroll-padding-bottom: calc(
|
||||
var(--actionBarHeight, 0px) + var(--toastHeight, 0px) +
|
||||
var(--sp-4) + env(safe-area-inset-bottom, 0px));
|
||||
scroll-padding-right: var(--actionRailW, 0px);
|
||||
}
|
||||
|
||||
/* ---------- Keyboard focus ----------
|
||||
Three focus rules existed for the whole tool, so tabbing through it relied on
|
||||
whatever ring the browser draws by default — which on these dark, custom-painted
|
||||
|
||||
@@ -46,7 +46,10 @@ const Toast = {
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add("toastVisible");
|
||||
this.publishHeight();
|
||||
});
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
@@ -61,6 +64,24 @@ const Toast = {
|
||||
}
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
this.publishHeight();
|
||||
},
|
||||
|
||||
// How much of the bottom of the window this is standing on.
|
||||
//
|
||||
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
|
||||
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
|
||||
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
|
||||
// this to the bar's own height to work out where a focused control has to stop, so
|
||||
// it is published rather than guessed: the toast is one line or two depending on the
|
||||
// message, and on a narrow screen the same message is taller again.
|
||||
publishHeight() {
|
||||
if (typeof document === "undefined" || !document.documentElement) return;
|
||||
const host = document.getElementById("toastHost");
|
||||
const height = host && host.firstElementChild
|
||||
? Math.round(host.getBoundingClientRect().height)
|
||||
: 0;
|
||||
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -664,6 +664,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Focus that lands underneath something fixed is moved out from under it.
|
||||
//
|
||||
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
|
||||
// works when the browser has to scroll to reach the control at all — then the
|
||||
// padding tells it where to stop. A control the browser already considers visible is
|
||||
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
|
||||
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
|
||||
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
|
||||
// nothing, because "nearest" was already satisfied.
|
||||
//
|
||||
// So the overlap is worked out here and the page is scrolled by exactly it. Named
|
||||
// overlays rather than every fixed element on the page, because this runs on every
|
||||
// focus change and walking the whole DOM to find three known things would be work
|
||||
// done ten times a second to reach the same answer. Anything inside one of them is
|
||||
// left alone — the bar's own buttons are meant to be on top of the page.
|
||||
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
|
||||
document.addEventListener("focusin", event => {
|
||||
const el = event.target;
|
||||
if (!el || typeof el.getBoundingClientRect !== "function") return;
|
||||
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width === 0 || box.height === 0) return;
|
||||
|
||||
let lift = 0;
|
||||
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
|
||||
const style = getComputedStyle(node);
|
||||
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
|
||||
if (style.position !== "fixed") return;
|
||||
const over = node.getBoundingClientRect();
|
||||
if (over.width === 0 || over.height === 0) return;
|
||||
// Only what is actually in the way: an overlay off to one side, like the
|
||||
// rail on a phone held sideways, is not covering a control in the middle.
|
||||
if (box.left >= over.right || box.right <= over.left) return;
|
||||
if (box.bottom <= over.top || box.top >= over.bottom) return;
|
||||
lift = Math.max(lift, box.bottom - over.top + 8);
|
||||
});
|
||||
|
||||
// Instant rather than smooth. This is the page getting out of the way of
|
||||
// something the dealer has already arrived at, and animating it would put the
|
||||
// control they are typing into in motion under them.
|
||||
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
|
||||
|
||||
// Scrolling is not always enough. The page only has as much room below its
|
||||
// content as the padding under the app gives it, and that is sized for the bar
|
||||
// — the toast stands on top of the bar, so a control at the very end of the page
|
||||
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
|
||||
// lift wanted, 26px of page to do it in.
|
||||
//
|
||||
// Sizing the padding for the toast as well would fix it by making the page grow
|
||||
// and shrink by 64px every time anything is announced, which is a worse thing to
|
||||
// do to a dealer than the fault. So the toast gives way instead: it is an
|
||||
// eight-second message about something that has already happened, and it is
|
||||
// standing on a control somebody has just navigated to. Only after the scroll
|
||||
// has been tried, so a toast that is not in the way is left alone.
|
||||
const toast = document.querySelector("#toastHost .toast");
|
||||
if (!toast) return;
|
||||
const after = el.getBoundingClientRect();
|
||||
const over = toast.getBoundingClientRect();
|
||||
const stillCovered = after.left < over.right && after.right > over.left &&
|
||||
after.bottom > over.top && after.top < over.bottom;
|
||||
if (stillCovered) Toast.dismiss();
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||
|
||||
[
|
||||
|
||||
@@ -292,6 +292,44 @@ skipped rather than judged, since it is not in the tree to begin with.
|
||||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||||
how it was checked.
|
||||
|
||||
## Where the keyboard lands
|
||||
|
||||
Two checks in `uiAudit.js` cover WCAG 2.2 SC 2.4.11, which says a focused control must
|
||||
not be completely hidden by something the page has put on top of it. Tabbing the whole
|
||||
page on a 390x844 phone found **16 stops resting completely covered and 27 more partly**,
|
||||
under the action bar; on the desktop, four more under a toast.
|
||||
|
||||
They are checked as the fix rather than as the symptom, because the symptom needs a
|
||||
keyboard and this file has no automation of its own:
|
||||
|
||||
- **`focusHasRoom()`** — `scroll-padding-bottom` on the scroll container has to reserve at
|
||||
least as much room as the fixed furniture standing at the bottom of the window. Only
|
||||
furniture crossing the middle of the screen counts, or the rail a landscape phone puts
|
||||
down the right-hand side would demand scroll padding the height of the whole window;
|
||||
and sticky elements inside a panel that scrolls on its own are excluded, because
|
||||
scroll-padding on the document would do nothing about them.
|
||||
- **`focusablesReachable()`** — nothing focusable may sit entirely outside the viewport
|
||||
inside a `position: fixed` subtree, since scrolling can never bring it back. The seat
|
||||
editor's bottom sheet was put away with `transform: translateY(105%)`, which moves a
|
||||
thing without putting it away: its Done button sat at y=850 on an 844px screen, still in
|
||||
the tab order and still on the accessibility tree. `visibility: hidden` puts it away.
|
||||
|
||||
### Half a fix looks exactly like a whole one
|
||||
|
||||
`scroll-padding-bottom` alone was written up as the answer and it is only half of it. It
|
||||
applies when the browser has to scroll to reach the control at all; a control the browser
|
||||
already considers visible is one it will not scroll to, and a fixed overlay on top of it
|
||||
does not enter into that decision. Measured: tabbing to Common rulings with a toast up
|
||||
left it 100% covered, and `scrollIntoView({block: "nearest"})` moved nothing, because
|
||||
"nearest" was already satisfied. The sweep had shown clean only because 89 tab presses
|
||||
outlast an eight-second toast.
|
||||
|
||||
So the other half is a `focusin` handler that measures the overlap and scrolls by it —
|
||||
and, when the page has no room left to scroll (26px available against 94px wanted, at
|
||||
1280x900), dismisses the toast instead. Sizing the page's bottom padding for the toast as
|
||||
well would have fixed it by making the page grow and shrink by 64px every time anything
|
||||
was announced, which is worse than the fault.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
|
||||
@@ -105,9 +105,11 @@ describe("the tokens themselves", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it.
|
||||
// which is picked per chip from the amount on it, and feedback.js for the
|
||||
// toast's height, which is one line or two depending on what is being said.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
"--toastHeight",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
|
||||
@@ -96,6 +96,97 @@ const LayoutAudit = {
|
||||
return { name: "every control has a name a screen reader can read", offenders };
|
||||
},
|
||||
|
||||
// ---- Focus has somewhere to land ----
|
||||
|
||||
// Everything fixed to the bottom of the window, as one band. The action bar and the
|
||||
// toast stack, so what matters is the top edge of the highest of them.
|
||||
//
|
||||
// Only things crossing the middle of the screen count. A landscape phone turns the
|
||||
// bar into a rail down the right-hand side, which is bottom-anchored and full height
|
||||
// — measuring that as "furniture at the bottom" would demand scroll padding the
|
||||
// height of the whole window.
|
||||
bottomFurniture() {
|
||||
const middle = innerWidth / 2;
|
||||
// A sticky element is anchored to whatever scrolls it, which is only the window
|
||||
// when nothing between it and the root scrolls. The standings table has a sticky
|
||||
// header inside a panel that scrolls on its own; it comes to rest against the
|
||||
// bottom of the window often enough to be picked up here, and it is not
|
||||
// furniture — scroll-padding on the document would do nothing about it.
|
||||
const anchoredToWindow = node => {
|
||||
if (getComputedStyle(node).position === "fixed") return true;
|
||||
for (let up = node.parentElement; up && up !== document.body; up = up.parentElement) {
|
||||
if (/auto|scroll|overlay/.test(getComputedStyle(up).overflowY)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [...document.querySelectorAll("*")].filter(node => {
|
||||
const cs = getComputedStyle(node);
|
||||
if (cs.position !== "fixed" && cs.position !== "sticky") return false;
|
||||
if (cs.visibility === "hidden" || cs.display === "none" || node.hidden) return false;
|
||||
const b = node.getBoundingClientRect();
|
||||
if (b.width === 0 || b.height === 0) return false;
|
||||
if (b.bottom < innerHeight - 2 || b.left > middle || b.right < middle) return false;
|
||||
return anchoredToWindow(node);
|
||||
});
|
||||
},
|
||||
|
||||
// WCAG 2.2 SC 2.4.11: a focused control must not be completely hidden by content the
|
||||
// page put on top of it.
|
||||
//
|
||||
// A fixed bar is not in the flow, so the browser does not know it is there: Tab to a
|
||||
// control near the bottom and it is scrolled flush against the edge of the window,
|
||||
// under the bar, and the browser considers it done. Tabbing the whole page on a
|
||||
// 390x844 phone found 16 stops resting completely covered and 27 more partly.
|
||||
//
|
||||
// Checked as the fix rather than the symptom, because the symptom needs a keyboard
|
||||
// and this file has no automation of its own: scroll-padding-bottom on the scroll
|
||||
// container has to reserve at least as much room as the furniture standing there.
|
||||
focusHasRoom() {
|
||||
const furniture = this.bottomFurniture();
|
||||
if (furniture.length === 0) {
|
||||
return { name: "focus has room under the fixed bar", offenders: [],
|
||||
skipped: "nothing is fixed to the bottom here" };
|
||||
}
|
||||
|
||||
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
|
||||
const needed = Math.round(innerHeight - highest);
|
||||
const declared = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom);
|
||||
const reserved = Number.isFinite(declared) ? Math.round(declared) : 0;
|
||||
|
||||
const offenders = reserved >= needed ? [] : [
|
||||
`${furniture.map(n => this.describe(n)).join(" + ")} stands ${needed}px tall, ` +
|
||||
`and scroll-padding-bottom reserves ${reserved}px — a control tabbed to down there ` +
|
||||
`comes to rest ${needed - reserved}px inside it`,
|
||||
];
|
||||
return { name: "focus has room under the fixed bar", offenders };
|
||||
},
|
||||
|
||||
// The other half: a control that scrolling can never reach at all.
|
||||
//
|
||||
// The seat editor is a bottom sheet on a phone, and it was put away with
|
||||
// `transform: translateY(105%)` — which moves it and nothing else. Parked below an
|
||||
// 844px window at y=850, its Done button was still in the tab order and still on the
|
||||
// accessibility tree, and because a fixed element does not move when the page does,
|
||||
// no amount of scrolling could bring it back. visibility:hidden puts it away; a
|
||||
// transform only moves it.
|
||||
focusablesReachable() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => {
|
||||
for (let node = el; node; node = node.parentElement) {
|
||||
if (getComputedStyle(node).position === "fixed") return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.filter(el => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0;
|
||||
})
|
||||
.map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`);
|
||||
|
||||
return { name: "no control is parked where scrolling can't reach it", offenders };
|
||||
},
|
||||
|
||||
// ---- Touch (see the mobile round) ----
|
||||
|
||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||
@@ -1145,6 +1236,8 @@ const LayoutAudit = {
|
||||
this.seatsOnTheTable(options.seatSlack),
|
||||
this.dialogActionsReachable(),
|
||||
this.controlNames(),
|
||||
this.focusHasRoom(),
|
||||
this.focusablesReachable(),
|
||||
this.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.textSelectable(),
|
||||
|
||||
Reference in New Issue
Block a user