From 7f74fcf9bffe8ddd14e34c9120a1be4117e45ee4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:01:59 +0100 Subject: [PATCH] fix: move toasts away from the focused control and keep focus clear of sticky headers inside panes --- app/accessibility.css | 8 ++- components/ui/__tests__/toast-layout.test.tsx | 40 ++++++++++++++ components/ui/toast.tsx | 53 ++++++++++++++++++- lib/__tests__/accessibility-css.test.ts | 8 +++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/app/accessibility.css b/app/accessibility.css index 556c85142..b8dbce7a6 100644 --- a/app/accessibility.css +++ b/app/accessibility.css @@ -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) ---------- * diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index a7422ab58..1b50a2896 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -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(<>); + 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'); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 106df1542..8ce5b0a98 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -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(null); // Where the focus was before the shortcut moved it into a toast. const returnFocusRef = useRef(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. diff --git a/lib/__tests__/accessibility-css.test.ts b/lib/__tests__/accessibility-css.test.ts index 7782152e8..58d6f8762 100644 --- a/lib/__tests__/accessibility-css.test.ts +++ b/lib/__tests__/accessibility-css.test.ts @@ -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('
').matches(panes)).toBe(true); + expect(el('
').matches(panes)).toBe(true); + }); +});