feat(ui): dialogs, confirms and context menus leave as gently as they come

They faded and scaled in but vanished at once. A small presence hook keeps
them for 170 ms after closing while the mirror of their entrance plays:
scaling down and fading on a desktop, sliding down on a phone where the
dialog is docked as a drawer, with the backdrop fading out. During the exit
they show their last content, since an owner often clears the subject in the
same step, and take no clicks. Reduced motion gets a short fade. Unit tests
keep the old instant close.
This commit is contained in:
Maurice
2026-11-06 15:51:28 +01:00
parent 6de15a92a4
commit 75d0a6181b
6 changed files with 183 additions and 15 deletions
+15 -6
View File
@@ -4,6 +4,7 @@ import { AlertTriangle, HelpCircle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useIsPhone } from '../../mobile/useIsPhone'
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
import { useLastOpen, usePresence } from './usePresence'
interface ConfirmDialogProps {
isOpen: boolean
@@ -28,7 +29,14 @@ function runConfirm(onConfirm: () => void): void {
}
}
export default function ConfirmDialog({
export default function ConfirmDialog(props: ConfirmDialogProps) {
const { mounted, closing } = usePresence(props.isOpen)
// The question stays readable while it fades, even when its owner clears the subject on close.
const shown = useLastOpen(props, props.isOpen)
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
}
function ConfirmDialogFrame({
isOpen,
onClose,
onConfirm,
@@ -38,7 +46,8 @@ export default function ConfirmDialog({
cancelLabel,
danger = true,
children,
}: ConfirmDialogProps) {
closing = false,
}: ConfirmDialogProps & { closing?: boolean }) {
const { t } = useTranslation()
const isPhone = useIsPhone()
@@ -57,14 +66,14 @@ export default function ConfirmDialog({
return () => document.removeEventListener('keydown', handleEsc, true)
}, [isOpen, handleEsc])
if (!isOpen) return null
if (!isOpen && !closing) return null
return createPortal(
<div
// Backdrop only — Escape (above) and the Cancel button below are the
// keyboard routes out of the dialog.
role="presentation"
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
@@ -74,7 +83,7 @@ export default function ConfirmDialog({
own, and the two buttons below it always stay in reach. */}
<div
role="presentation"
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
onClick={e => e.stopPropagation()}
>
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
@@ -117,7 +126,7 @@ export default function ConfirmDialog({
// with its tile, the message below, the answers in a bar at the foot.
<div
role="presentation"
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
+9 -3
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useLayoutEffect, useRef, type ComponentType, type CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { LucideIcon } from 'lucide-react'
import { useLastOpen, usePresence } from './usePresence'
interface MenuItem {
label?: string
@@ -50,6 +51,13 @@ interface ContextMenuProps {
}
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
const { mounted, closing } = usePresence(menu != null, 130)
// The items stay on screen while the menu fades, after its owner set it to null.
const shown = useLastOpen(menu, menu != null)
return mounted && shown ? <ContextMenuFrame menu={shown} onClose={onClose} closing={closing} /> : null
}
function ContextMenuFrame({ menu, onClose, closing }: { menu: MenuState; onClose: () => void; closing: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -120,10 +128,8 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
}
}, [menu])
if (!menu) return null
return createPortal(
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className="trek-popover-enter" style={{
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className={`trek-popover-enter ${closing ? 'trek-popover-exit pointer-events-none' : ''}`} style={{
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
border: '1px solid var(--border-primary)',
+12 -6
View File
@@ -5,6 +5,7 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
import { useTranslation } from '../../i18n'
import { Tooltip } from './Tooltip'
import { focusDialog, trapTab } from './dialogFocus'
import { useLastOpen, usePresence } from './usePresence'
const sizeClasses: Record<string, string> = {
sm: 'max-w-sm',
@@ -36,7 +37,11 @@ interface ModalProps {
}
export default function Modal({ isOpen, ...frame }: ModalProps) {
return isOpen ? <ModalFrame {...frame} /> : null
const { mounted, closing } = usePresence(isOpen)
// While it leaves, the dialog shows what it showed last: its owner often
// clears the item in the same step that closes it.
const shown = useLastOpen(frame, isOpen)
return mounted ? <ModalFrame {...shown} closing={closing} /> : null
}
function ModalFrame({
@@ -47,7 +52,8 @@ function ModalFrame({
footer,
hideCloseButton = false,
align = 'center',
}: Omit<ModalProps, 'isOpen'>) {
closing = false,
}: Omit<ModalProps, 'isOpen'> & { closing?: boolean }) {
const { t } = useTranslation()
const panelRef = useRef<HTMLDivElement>(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
@@ -55,8 +61,8 @@ function ModalFrame({
const [focusedBefore] = useState(() => document.activeElement)
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
if (e.key === 'Escape' && !closing) onClose()
}, [onClose, closing])
useEffect(() => {
document.addEventListener('keydown', handleEsc)
@@ -85,7 +91,7 @@ function ModalFrame({
// click-away, the panel only keeps that click from reaching it. Escape
// and the header's close button are the keyboard route out.
role="presentation"
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]`}
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
@@ -99,7 +105,7 @@ function ModalFrame({
tabIndex={-1}
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
className={`
trek-modal-enter outline-none
trek-modal-enter ${closing ? 'trek-modal-exit' : ''} outline-none
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
@@ -0,0 +1,64 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/** The hook outside the test mode, where it really lingers for the exit animation. */
async function loadAnimated() {
vi.resetModules();
vi.stubEnv('MODE', 'production');
return import('./usePresence');
}
afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
});
describe('usePresence', () => {
it('stays mounted and closing for the exit, then unmounts', async () => {
vi.useFakeTimers();
const { usePresence } = await loadAnimated();
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: true } });
expect(hook.result.current).toEqual({ mounted: true, closing: false });
hook.rerender({ open: false });
expect(hook.result.current).toEqual({ mounted: true, closing: true });
act(() => {
vi.advanceTimersByTime(170);
});
expect(hook.result.current).toEqual({ mounted: false, closing: false });
});
it('never mounts something that was never open, and reopens straight from closing', async () => {
vi.useFakeTimers();
const { usePresence } = await loadAnimated();
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: false } });
expect(hook.result.current.mounted).toBe(false);
hook.rerender({ open: true });
hook.rerender({ open: false });
hook.rerender({ open: true });
expect(hook.result.current).toEqual({ mounted: true, closing: false });
});
it('closes at once under the unit tests', async () => {
vi.resetModules();
const { usePresence } = await import('./usePresence');
const hook = renderHook(({ open }) => usePresence(open), { initialProps: { open: true } });
hook.rerender({ open: false });
expect(hook.result.current.mounted).toBe(false);
});
});
describe('useLastOpen', () => {
it('keeps the last value seen while open', async () => {
const { useLastOpen } = await import('./usePresence');
const hook = renderHook(({ value, open }) => useLastOpen(value, open), {
initialProps: { value: 'Delete Paris?' as string | null, open: true },
});
hook.rerender({ value: null, open: false });
expect(hook.result.current).toBe('Delete Paris?');
hook.rerender({ value: 'Delete Rome?', open: true });
expect(hook.result.current).toBe('Delete Rome?');
});
});
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from 'react';
/**
* How long a closing dialog, confirm or menu stays for its exit animation
* (index.css). None under the unit tests: hundreds of them close a confirm and
* check at once that it is gone, which is what the user sees once the fade ends.
*/
export const EXIT_MS = import.meta.env.MODE === 'test' ? 0 : 170;
/**
* Keeps something on screen for its exit animation after it was told to close:
* `mounted` stays true for EXIT_MS once `open` turns false, with `closing` set
* meanwhile so the caller can add the exit class and stop taking clicks.
*/
export function usePresence(open: boolean, ms: number = EXIT_MS): { mounted: boolean; closing: boolean } {
const animated = ms > 0 && EXIT_MS > 0;
const [lingering, setLingering] = useState(false);
const wasOpen = useRef(open);
useEffect(() => {
if (open) {
wasOpen.current = true;
setLingering(false);
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
if (!animated) return;
setLingering(true);
const timer = window.setTimeout(() => setLingering(false), ms);
return () => window.clearTimeout(timer);
}, [open, ms, animated]);
// The first render after closing already counts as closing, before the effect runs.
const closing = animated && !open && (lingering || wasOpen.current);
return { mounted: open || closing, closing };
}
/**
* The last value seen while open: what a closing popup shows during its exit,
* since its owner often clears the data in the same step that closes it.
*/
export function useLastOpen<T>(value: T, open: boolean): T {
const last = useRef(value);
if (open) last.current = value;
return open ? value : last.current;
}
+36
View File
@@ -568,6 +568,42 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
.trek-backdrop-enter {
animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* Closing: the mirror of each entrance, shorter and easing in, so a dialog,
confirm or menu leaves as gently as it came (components/shared/usePresence.ts
keeps it mounted for EXIT_MS). Added next to the entrance class rather than
instead of it, because on phones the entrance class also docks the dialog. */
@keyframes trek-modal-exit {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.97); }
}
@keyframes trek-drawer-exit {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(40%); }
}
@keyframes trek-backdrop-exit {
from { opacity: 1; }
to { opacity: 0; }
}
.trek-modal-enter.trek-modal-exit,
.trek-popover-enter.trek-popover-exit {
animation: trek-modal-exit 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.trek-backdrop-enter.trek-backdrop-exit {
animation: trek-backdrop-exit 170ms ease-in forwards;
}
@media (max-width: 639px) {
.trek-modal-enter.trek-modal-exit {
animation: trek-drawer-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
}
@media (prefers-reduced-motion: reduce) {
.trek-modal-enter.trek-modal-exit,
.trek-popover-enter.trek-popover-exit,
.trek-backdrop-enter.trek-backdrop-exit {
animation: trek-backdrop-exit 120ms ease-out forwards;
}
}
.trek-toast-enter {
animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1);
will-change: transform, opacity;