mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
fix: move toasts away from the focused control and keep focus clear of sticky headers inside panes
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user