Hold'em dealer: making the table and the bar fit the screen they are on #119
@@ -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