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);
+ });
+});