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:
@@ -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