diff --git a/app/globals.css b/app/globals.css index 4e41210eb..7a0579c93 100644 --- a/app/globals.css +++ b/app/globals.css @@ -643,14 +643,6 @@ html[data-window-controls] [data-own-window-close] { display: none; } -/* - * A sheet docked at the window's end (the calendar's event and task panels) has its title - * and close at the top and Save at the foot: toasts stand beside it, not on it (pim-p2-19). - */ -body:has([data-end-sheet]) [data-toast-container] { - inset-inline-end: calc(var(--end-sheet-width, 400px) + 1.25rem); -} - /* Toasts that step out of the focus's way go to the top end corner: below the controls there. */ html[data-window-controls] [data-toast-container][data-dodging] { top: calc(var(--titlebar-row-height) + 0.75rem) !important; diff --git a/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx index 148a6a4e5..de252fba2 100644 --- a/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx +++ b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx @@ -4,39 +4,25 @@ import { describe, expect, it } from 'vitest'; // pim-p2-19: toasts at the top right covered the event sheet's title and // close button; at the bottom right they covered its Save. They stand beside -// a sheet docked at the window's end now. jsdom has no layout, so the rule is -// checked through the cascade: the stylesheet's own rule, applied in jsdom. +// a sheet docked at the window's end, or above its buttons when the window +// has no room beside it. lib/toast-placement does that for whatever is marked +// (components/ui/__tests__/toast-placement.test.tsx); here: the marks. -const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8'); - -function endSheetRule(): string { - const match = /body:has\(\[data-end-sheet\]\) \[data-toast-container\] \{[^}]*\}/.exec(css); - expect(match, 'no end-sheet rule for toasts in globals.css').not.toBeNull(); - return match![0]; -} +const read = (...parts: string[]) => readFileSync(path.join(process.cwd(), ...parts), 'utf8'); describe('toasts beside an end sheet', () => { - it('move the stack past the sheet while one is open', () => { - const style = document.createElement('style'); - style.textContent = endSheetRule(); - document.head.appendChild(style); - const container = document.createElement('div'); - container.setAttribute('data-toast-container', ''); - document.body.appendChild(container); - expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).not.toContain('400px'); - - const sheet = document.createElement('div'); - sheet.setAttribute('data-end-sheet', ''); - document.body.appendChild(sheet); - expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).toBe('calc(var(--end-sheet-width, 400px) + 1.25rem)'); - style.remove(); - container.remove(); - sheet.remove(); + it('leaves the place to lib/toast-placement, with no fixed width in the stylesheet', () => { + expect(read('app', 'globals.css')).not.toMatch(/\[data-end-sheet\][^{]*\[data-toast-container\]/); }); it('marks the calendar event and task panels as end sheets', () => { - const app = readFileSync(path.join(process.cwd(), 'components', 'calendar', 'calendar-app.tsx'), 'utf8'); + const app = read('components', 'calendar', 'calendar-app.tsx'); expect(app).toMatch(/data-testid="event-panel" data-end-sheet=""/); expect(app).toMatch(/data-end-sheet="" className="w-\[400px\][^"]*"[\s\S]{0,40} { + expect(read('components', 'calendar', 'event-modal.tsx').match(/data-toast-avoid="bottom"/g)).toHaveLength(2); + expect(read('components', 'calendar', 'task-modal.tsx')).toContain('data-toast-avoid="bottom"'); + }); }); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index a4b0ed5ed..f9502d0b4 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1327,7 +1327,7 @@ export function EventModal({ )} {/* Action Bar */} -
+
{onDelete && canEditBody && ( onDelete(event.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive"> @@ -1721,7 +1721,7 @@ export function EventModal({
-
+
{isEdit && onDelete && ( onDelete(event!.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive"> diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index eab0f174f..7f698df23 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -393,7 +393,7 @@ export function TaskModal({
{/* Footer */} -
+
{isEdit && onDelete && task && ( :

next page

; + } + const { rerender } = render(<>); + act(() => { toast.success('Settings saved'); }); + act(() => { (document.getElementById('save') as HTMLElement).focus(); }); + await nextFrame(); + expect(stack()).toHaveAttribute('data-dodging'); + + rerender(<>); + await nextFrame(); + expect(stack()).not.toHaveAttribute('data-dodging'); + expect(stack().style.bottom).toBe('20px'); + }); + + it('stays at the bottom while the focus is in the editor of the reply dock under it', async () => { + // Above the dock's footer the stack covered the lower corner of the + // editor; stepping up for it put the toasts over the reader's toolbar. + render( + <> + + +
Thanks
+ , + ); + act(() => { (document.getElementById('editor') as HTMLElement).focus(); }); + act(() => { toast.success('Archived'); }); + await nextFrame(); + expect(stack()).not.toHaveAttribute('data-dodging'); + expect(stack().style.bottom).toBe(`${56 + 12}px`); + }); + + it('holds still while the mouse is on its way to it, and catches up once it leaves', async () => { + const { rerender } = render(<>); + act(() => { toast.success('Archived'); }); + expect(stack().style.bottom).toBe('20px'); + + // The pointer is next to the stack when the quick reply box appears. + fireEvent.pointerMove(document, { pointerType: 'mouse', clientX: W - 200, clientY: H - 60 }); + rerender(<>); + await nextFrame(); + expect(stack().style.bottom).toBe('20px'); + + fireEvent.pointerMove(document, { pointerType: 'mouse', clientX: 100, clientY: 100 }); + await nextFrame(); + expect(stack().style.bottom).toBe(`${90 + 12}px`); + }); + + it('keeps every toast while the pointer is on the stack, so none goes from under it', () => { + render(); + act(() => { toast.success('First'); }); + act(() => { toast.success('Second'); }); + fireEvent.pointerOver(screen.getByText('Second'), { pointerType: 'mouse' }); + act(() => { vi.advanceTimersByTime(20000); }); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + + fireEvent.pointerLeave(stack(), { pointerType: 'mouse' }); + act(() => { vi.advanceTimersByTime(6000); }); + expect(screen.queryByText('First')).not.toBeInTheDocument(); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 8de8acca0..b43572c76 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"; import { TOUCH_HIT_AREA } from "@/components/ui/button"; import { useContextMenuOpen } from "@/components/ui/context-menu"; import { useMediaQuery, narrowerThan } from "@/hooks/use-media-query"; +import { computeToastPlacement, stackWouldCoverFocus, TOAST_AVOID_BOTTOM, TOAST_AVOID_END, TOAST_EDGE } from "@/lib/toast-placement"; export type ToastType = "success" | "error" | "info" | "warning"; @@ -62,6 +63,8 @@ interface ToastProps { onClose: (id: string) => void; /** Kept out of sight for now (a menu is open over it): the countdown waits. */ held?: boolean; + /** The pointer is on the stack: counts as hovering every toast in it. */ + stackHovered?: boolean; /** The toast goes away with the keyboard focus in it: put the focus back. */ onFocusLost?: () => void; } @@ -105,7 +108,7 @@ const progressBarStyles = { // A sideways swipe this far (px) on a touch screen dismisses the toast. const SWIPE_DISMISS_DISTANCE = 72; -export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastProps) { +export function ToastItem({ toast, onClose, held = false, stackHovered = false, onFocusLost }: ToastProps) { const tCommon = useTranslations("common"); const Icon = icons[toast.type]; const [exiting, setExiting] = useState(false); @@ -150,7 +153,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr }, [toast, dismiss]); const canPause = toast.pauseOnHover !== false; - const waiting = paused || held || (canPause && (hovered || focused)); + const waiting = paused || held || (canPause && (hovered || focused || stackHovered)); useEffect(() => { if (!toast.duration || toast.duration <= 0 || isProgress) return; @@ -361,38 +364,121 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr } /** - * True when the toast stack, in its usual place at the bottom, hides at - * least half of the element that has the focus: a button or a field. A - * large element it only overlaps at a corner (the composer's text, the - * quick reply box) stays usable, and moving the stack to the top for it put - * the toasts over the composer's close button and the reader's toolbar. 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). + * Keeps the stack where lib/toast-placement puts it, while toasts are up: at + * the bottom end corner above what is docked at the bottom on screen now, + * beside a sheet at the window's end, and at the top end corner while it + * would cover the focused element (WCAG 2.4.11; not on a phone, where the + * top is the header with Back and the message actions, #1472). Measured + * again in the frame after anything on the page changes, scrolls or + * resizes, and when the focus moves or the focused element goes. It stays + * put while the pointer is on or near it, so an Undo doesn't move away from + * the pointer heading for it. */ -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(); - const overlapWidth = Math.min(f.right, box.right) - Math.max(f.left, box.left); - const overlapHeight = Math.min(f.bottom, bottom) - Math.max(f.top, top); - if (overlapWidth <= 0 || overlapHeight <= 0) return false; - const area = Math.max(f.width, 0) * Math.max(f.height, 0); - return area === 0 || overlapWidth * overlapHeight >= area / 2; +function useToastPlacement( + containerRef: React.RefObject, + active: boolean, + phone: boolean, +): void { + useLayoutEffect(() => { + const stack = containerRef.current; + if (!active || !stack) return; + let frame = 0; + let pointer: { x: number; y: number } | null = null; + const near = () => { + if (!pointer || phone) return false; + const r = stack.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return false; + const m = POINTER_HOLD_MARGIN; + return pointer.x >= r.left - m && pointer.x <= r.right + m && pointer.y >= r.top - m && pointer.y <= r.bottom + m; + }; + const apply = (force = false) => { + frame = 0; + if (!force && near()) return; + const rtl = getComputedStyle(stack).direction === "rtl"; + const place = computeToastPlacement(stack, { phone, rtl }); + const dodge = !phone && stackWouldCoverFocus(stack, place, rtl); + const set = (prop: "bottom" | "top" | "insetInlineEnd", value: string) => { + if (stack.style[prop] !== value) stack.style[prop] = value; + }; + set("bottom", dodge ? "auto" : `${place.bottom}px`); + set("top", dodge ? `${TOAST_EDGE}px` : ""); + set("insetInlineEnd", place.end === null ? "" : `${place.end}px`); + if (dodge) { + if (stack.dataset.dodging === undefined) stack.dataset.dodging = "true"; + } else if (stack.dataset.dodging !== undefined) { + delete stack.dataset.dodging; + } + }; + const schedule = () => { + if (!frame) frame = requestAnimationFrame(() => apply()); + }; + // Before the first paint: a new toast appears in its place. + apply(true); + + // Something docked resized without a change in the page (a field growing). + const watched = new Set(); + const sizes = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule); + const watch = () => { + if (!sizes) return; + const now = new Set(document.querySelectorAll(`${TOAST_AVOID_BOTTOM}, ${TOAST_AVOID_END}`)); + for (const el of watched) if (!now.has(el)) { sizes.unobserve(el); watched.delete(el); } + for (const el of now) if (!watched.has(el)) { sizes.observe(el); watched.add(el); } + }; + watch(); + // The page changed: a box opened, closed or grew, a route rendered another + // page. Changes inside the stack itself (its own toasts) don't count. + const mutations = new MutationObserver((records) => { + if (!records.some((r) => !stack.contains(r.target))) return; + watch(); + schedule(); + }); + mutations.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["class", "style", "hidden", "open", "data-toast-avoid", "data-end-sheet"], + }); + + const onPointer = (e: PointerEvent) => { + if (e.pointerType !== "mouse" && e.pointerType !== "pen") return; + const wasNear = near(); + pointer = { x: e.clientX, y: e.clientY }; + // Leaving: catch up on what changed meanwhile. + if (wasNear && !near()) schedule(); + }; + const onPointerOut = (e: PointerEvent) => { + if (e.relatedTarget) return; + pointer = null; + schedule(); + }; + // Focus moved, or the focused element went (no focusin then). + document.addEventListener("focusin", schedule); + document.addEventListener("focusout", schedule); + document.addEventListener("scroll", schedule, { capture: true, passive: true }); + document.addEventListener("transitionend", schedule, true); + document.addEventListener("animationend", schedule, true); + document.addEventListener("pointermove", onPointer, { passive: true }); + document.addEventListener("pointerout", onPointerOut); + window.addEventListener("resize", schedule); + return () => { + cancelAnimationFrame(frame); + mutations.disconnect(); + sizes?.disconnect(); + document.removeEventListener("focusin", schedule); + document.removeEventListener("focusout", schedule); + document.removeEventListener("scroll", schedule, { capture: true }); + document.removeEventListener("transitionend", schedule, true); + document.removeEventListener("animationend", schedule, true); + document.removeEventListener("pointermove", onPointer); + document.removeEventListener("pointerout", onPointerOut); + window.removeEventListener("resize", schedule); + }; + }, [containerRef, active, phone]); } +/** How close (px) the mouse has to be for the stack to hold still. */ +const POINTER_HOLD_MARGIN = 48; + export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) { const tCenter = useTranslations("notification_center"); const [mounted, setMounted] = useState(false); @@ -405,38 +491,19 @@ 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. Not on a phone: there the top of the - // screen is the header with Back and the message actions, which an Undo - // toast covered after a delete (#1472), and the stack already rides above - // the bottom bars. - const [dodge, setDodge] = useState(false); + // The pointer is on the stack: every countdown waits, so no toast goes + // and none moves under the pointer. + const [stackHovered, setStackHovered] = useState(false); useEffect(() => { setMounted(true); }, []); const hasToasts = toasts.length > 0; - useLayoutEffect(() => { - if (!hasToasts || phone) { - 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, phone]); + useToastPlacement(containerRef, mounted && hasToasts, phone); + useEffect(() => { + if (!hasToasts) setStackHovered(false); + }, [hasToasts]); useEffect(() => { if (!hasToasts) return; const onKeyDown = (e: KeyboardEvent) => { @@ -471,22 +538,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: return createPortal(
0 ? "region" : undefined} aria-label={toasts.length > 0 ? tCenter("title") : undefined} style={{ pointerEvents: "none" }} + onPointerOver={(e) => { if (e.pointerType === "mouse") setStackHovered(true); }} + onPointerLeave={() => setStackHovered(false)} > {toasts.map((toast) => (
- +
))}
, diff --git a/hooks/use-bottom-controls.ts b/hooks/use-bottom-controls.ts index 9dcf2f2de..e0b91fc89 100644 --- a/hooks/use-bottom-controls.ts +++ b/hooks/use-bottom-controls.ts @@ -7,7 +7,9 @@ import { useEffect, useState } from 'react'; * bar and a viewer's action bar, on any screen an editor's Save/Cancel * footer. While any is on screen, * `--bottom-controls-height` on says how far from the bottom of the - * window the topmost of them reaches, and toasts sit above it. + * window the topmost of them reaches, for what floats above them (the + * install and push prompts). Each is also marked data-toast-avoid="bottom", + * so the toasts keep clear of it (lib/toast-placement). */ const bars = new Set(); let frame = 0; @@ -44,6 +46,7 @@ export function useBottomControls(active = true): (el: HTMLElement | null) => vo useEffect(() => { if (!el || !active) return; bars.add(el); + el.setAttribute('data-toast-avoid', 'bottom'); update(); const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule); observer?.observe(el); @@ -52,6 +55,7 @@ export function useBottomControls(active = true): (el: HTMLElement | null) => vo observer?.disconnect(); window.removeEventListener('resize', schedule); bars.delete(el); + el.removeAttribute('data-toast-avoid'); update(); }; }, [el, active]); diff --git a/lib/toast-placement.ts b/lib/toast-placement.ts new file mode 100644 index 000000000..24914a330 --- /dev/null +++ b/lib/toast-placement.ts @@ -0,0 +1,121 @@ +/** + * Where the toast stack goes, for the whole app. One rule: at the bottom end + * corner of the window (bottom left in RTL; across the bottom on a phone), + * above whatever is docked at the bottom on screen now, and beside a sheet + * docked at the window's end when there is room for it. + * + * The things to keep clear mark themselves: + * data-toast-avoid="bottom" docked at the bottom: the quick reply box, a + * composer's or form's footer, the phone's bars + * (hooks/use-bottom-controls marks those) + * data-end-sheet a panel docked at the window's end: the + * calendar's event and task panels + * + * Everything is measured where it is on screen at the moment, never kept from + * earlier: an offset worked out from a box that then moved, closed or went + * with a page change left toasts in the middle of the content. + */ + +export const TOAST_AVOID_BOTTOM = '[data-toast-avoid="bottom"]'; +export const TOAST_AVOID_END = '[data-end-sheet]'; + +/** Distance from the window's edges (px). */ +export const TOAST_EDGE = 20; +/** The same on a phone, where the stack spans the screen. */ +export const TOAST_PHONE_EDGE = 12; +/** Room left above something docked at the bottom (px). */ +export const TOAST_GAP = 12; +/** The stack's width when it can't be measured (w-[360px] in toast.tsx). */ +const STACK_WIDTH = 360; + +export interface ToastPlacement { + /** From the window's bottom edge to the stack's bottom (px). */ + bottom: number; + /** From the window's end edge (px); null on a phone, where the stack spans the screen. */ + end: number | null; +} + +function shownRect(el: Element): DOMRect | null { + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return null; + if (getComputedStyle(el).visibility === 'hidden') return null; + return r; +} + +function viewport(): { width: number; height: number } { + const root = document.documentElement; + return { + width: root.clientWidth || window.innerWidth, + height: window.innerHeight || root.clientHeight, + }; +} + +/** The stack's place, from what is on screen now. */ +export function computeToastPlacement(stack: HTMLElement, opts: { phone: boolean; rtl: boolean }): ToastPlacement { + const { width: vw, height: vh } = viewport(); + const width = Math.min(stack.offsetWidth || STACK_WIDTH, vw); + + // Beside a sheet at the window's end, when the stack fits next to it. + // Without room it stays in the corner and clears the sheet's footer below. + let end: number | null = opts.phone ? null : TOAST_EDGE; + if (end !== null) { + for (const sheet of document.querySelectorAll(TOAST_AVOID_END)) { + if (stack.contains(sheet)) continue; + const r = shownRect(sheet); + if (!r) continue; + const atEnd = opts.rtl ? r.left <= 1 : r.right >= vw - 1; + if (!atEnd) continue; + const beside = (opts.rtl ? r.right : vw - r.left) + TOAST_EDGE; + if (beside + width + TOAST_EDGE <= vw) end = Math.max(end, beside); + } + } + + // The columns the stack covers. + const left = end === null ? TOAST_PHONE_EDGE : opts.rtl ? end : vw - end - width; + const right = end === null ? vw - TOAST_PHONE_EDGE : left + width; + + // Above what is docked at the bottom under those columns. Only what is on + // screen and sits in the lower half counts: a box scrolled out of view, or + // one standing in the middle, would put the toasts over the content. + let bottom = opts.phone ? TOAST_PHONE_EDGE : TOAST_EDGE; + for (const el of document.querySelectorAll(TOAST_AVOID_BOTTOM)) { + if (stack.contains(el)) continue; + const r = shownRect(el); + if (!r) continue; + if (r.right <= left || r.left >= right) continue; + if (r.top >= vh || r.bottom <= 0) continue; + if (r.top < vh / 2) continue; + bottom = Math.max(bottom, vh - r.top + TOAST_GAP); + } + return { bottom: Math.round(bottom), end }; +} + +/** + * True when the stack, at `place`, would hide at least half of the element + * that has the focus: a button or a one-line field. A large element it only + * overlaps at a corner stays usable, and a text area or editor never counts: + * moving the stack to the top for one put the toasts over the composer's close + * button and the reader's toolbar. + */ +export function stackWouldCoverFocus(stack: HTMLElement, place: ToastPlacement, rtl = false): boolean { + const focused = document.activeElement as HTMLElement | null; + if (!focused || focused === document.body || !focused.isConnected || stack.contains(focused)) return false; + // Text being written: the caret moves, and the stack over the lower corner + // of the reply dock's editor sent it to the top, over the toolbar. + if (focused.tagName === 'TEXTAREA' || focused.isContentEditable || focused.closest('[contenteditable]:not([contenteditable="false"])')) return false; + const height = stack.offsetHeight || stack.getBoundingClientRect().height; + if (height === 0) return false; + const { width: vw, height: vh } = viewport(); + const width = Math.min(stack.offsetWidth || STACK_WIDTH, vw); + const bottom = vh - place.bottom; + const top = bottom - height; + const end = place.end ?? TOAST_PHONE_EDGE; + const left = rtl ? end : vw - end - width; + const right = left + width; + const f = focused.getBoundingClientRect(); + const overlapWidth = Math.min(f.right, right) - Math.max(f.left, left); + const overlapHeight = Math.min(f.bottom, bottom) - Math.max(f.top, top); + if (overlapWidth <= 0 || overlapHeight <= 0) return false; + const area = Math.max(f.width, 0) * Math.max(f.height, 0); + return area === 0 || overlapWidth * overlapHeight >= area / 2; +}