fix: move toasts away from the focused control and keep focus clear of sticky headers inside panes

This commit is contained in:
Linus Rath
2026-10-29 19:38:45 +01:00
parent b22968a7df
commit 7f74fcf9bf
4 changed files with 107 additions and 2 deletions
+7 -1
View File
@@ -134,10 +134,16 @@
:is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"], .focus-inset):focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1) !important;
}
/* The focused element scrolls clear of sticky headers and the bottom bar. */
/* The focused element scrolls clear of sticky headers and the bottom bar,
in the page and inside the panes, which scroll on their own (scroll
padding belongs to the scrolling box, so the root's alone did nothing
there). :where() keeps the panes' rule overridable. */
:root {
scroll-padding-block: 4rem;
}
:where(.overflow-y-auto, .overflow-auto, .overflow-y-scroll, .overflow-scroll) {
scroll-padding-block: 3rem;
}
/* ---------- Large text in narrow panes (WCAG 1.4.4, 1.4.10) ----------
*
@@ -168,3 +168,43 @@ describe('toasts and open menus', () => {
expect(toastContainer()).not.toHaveClass('invisible');
});
});
// A toast covered the focused Save button at the foot of the event editor
// (WCAG 2.4.11). The stack moves to the top corner while it would cover the
// focused element, and back once the focus is elsewhere.
describe('toasts and the focused element', () => {
beforeEach(() => {
vi.useFakeTimers();
useToastStore.setState({ toasts: [] });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
function rect(top: number, left: number, width: number, height: number) {
return { top, left, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
it('moves out from over the focused button and comes back when the focus leaves', () => {
const h = window.innerHeight;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.hasAttribute('data-toast-container')) return rect(h - 100, 600, 360, 80);
if (this.id === 'save') return rect(h - 70, 700, 80, 36);
return rect(10, 10, 80, 36);
});
render(<><Toasts /><button id="save">Save</button><button id="title">Title</button></>);
act(() => { toast.success('Event created'); });
act(() => { vi.advanceTimersByTime(20); });
expect(toastContainer()).not.toHaveAttribute('data-dodging');
act(() => { (document.getElementById('save') as HTMLElement).focus(); });
act(() => { vi.advanceTimersByTime(20); });
expect(toastContainer()).toHaveAttribute('data-dodging');
expect(toastContainer()).toHaveClass('top-5');
act(() => { (document.getElementById('title') as HTMLElement).focus(); });
act(() => { vi.advanceTimersByTime(20); });
expect(toastContainer()).not.toHaveAttribute('data-dodging');
});
});
+52 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback, useRef, useId } from "react";
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useId } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
@@ -354,6 +354,31 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
);
}
/**
* True when the toast stack, in its usual place at the bottom, overlaps the
* element that has the focus. The stack's own box is measured where it is
* now; while it sits at the top, its bottom place is worked out from the gap
* it left there (data-bottom-gap).
*/
export function stackWouldCoverFocus(stack: HTMLElement | null): boolean {
if (!stack) return false;
const focused = document.activeElement as HTMLElement | null;
if (!focused || focused === document.body || stack.contains(focused)) return false;
const box = stack.getBoundingClientRect();
if (box.height === 0) return false;
let top = box.top;
let bottom = box.bottom;
if (stack.dataset.dodging) {
const gap = Number(stack.dataset.bottomGap || 20);
bottom = window.innerHeight - gap;
top = bottom - box.height;
} else {
stack.dataset.bottomGap = String(Math.max(0, window.innerHeight - box.bottom));
}
const f = focused.getBoundingClientRect();
return f.right > box.left && f.left < box.right && f.bottom > top && f.top < bottom;
}
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
const tCenter = useTranslations("notification_center");
const [mounted, setMounted] = useState(false);
@@ -366,12 +391,35 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
const containerRef = useRef<HTMLDivElement>(null);
// Where the focus was before the shortcut moved it into a toast.
const returnFocusRef = useRef<HTMLElement | null>(null);
// The stack moves to the top corner while it would cover the element that
// has the keyboard focus (WCAG 2.4.11): a Save button at the foot of the
// event editor sat right under it.
const [dodge, setDodge] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const hasToasts = toasts.length > 0;
useLayoutEffect(() => {
if (!hasToasts) {
setDodge(false);
return;
}
let frame = 0;
const check = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => setDodge(stackWouldCoverFocus(containerRef.current)));
};
check();
document.addEventListener("focusin", check);
window.addEventListener("resize", check);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("focusin", check);
window.removeEventListener("resize", check);
};
}, [hasToasts, toasts.length]);
useEffect(() => {
if (!hasToasts) return;
const onKeyDown = (e: KeyboardEvent) => {
@@ -414,8 +462,11 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
// (the tab bar, or whichever bar registered with useBottomControls).
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
"max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]",
// Out of the way of the focused element: the top end corner.
dodge && "top-5 bottom-auto! max-sm:top-[calc(env(safe-area-inset-top)+0.75rem)]",
held && "invisible"
)}
data-dodging={dodge || undefined}
// Not a live region: the toast store reads each toast out through
// lib/announce (errors assertively). A labelled region while toasts
// are up, so screen reader users can find them.
+8
View File
@@ -177,3 +177,11 @@ describe('large text in narrow panes', () => {
expect(wrap.some((s) => rowLabel.matches(s))).toBe(false);
});
});
describe('scroll padding', () => {
it('keeps a focused element clear of sticky headers inside the scrolling panes too', () => {
const panes = selectorsOf('scroll-padding-block: 3rem;').join(', ');
expect(el('<div class="flex-1 overflow-y-auto"></div>').matches(panes)).toBe(true);
expect(el('<div class="overflow-auto"></div>').matches(panes)).toBe(true);
});
});