diff --git a/client/src/components/shared/ConfirmDialog.tsx b/client/src/components/shared/ConfirmDialog.tsx index 341c36a0c..561b7663b 100644 --- a/client/src/components/shared/ConfirmDialog.tsx +++ b/client/src/components/shared/ConfirmDialog.tsx @@ -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 ? : 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(
@@ -74,7 +83,7 @@ export default function ConfirmDialog({ own, and the two buttons below it always stay in reach. */}
e.stopPropagation()} >
@@ -117,7 +126,7 @@ export default function ConfirmDialog({ // with its tile, the message below, the answers in a bar at the foot.
e.stopPropagation()} >
diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index 0d6e71ee5..6abccf5ad 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -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 ? : null +} + +function ContextMenuFrame({ menu, onClose, closing }: { menu: MenuState; onClose: () => void; closing: boolean }) { const ref = useRef(null) useEffect(() => { @@ -120,10 +128,8 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) { } }, [menu]) - if (!menu) return null - return createPortal( -
= { sm: 'max-w-sm', @@ -36,7 +37,11 @@ interface ModalProps { } export default function Modal({ isOpen, ...frame }: ModalProps) { - return isOpen ? : 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 ? : null } function ModalFrame({ @@ -47,7 +52,8 @@ function ModalFrame({ footer, hideCloseButton = false, align = 'center', -}: Omit) { + closing = false, +}: Omit & { closing?: boolean }) { const { t } = useTranslation() const panelRef = useRef(null) const mouseDownTarget = useRef(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)] diff --git a/client/src/components/shared/usePresence.test.ts b/client/src/components/shared/usePresence.test.ts new file mode 100644 index 000000000..4d8e777e2 --- /dev/null +++ b/client/src/components/shared/usePresence.test.ts @@ -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?'); + }); +}); diff --git a/client/src/components/shared/usePresence.ts b/client/src/components/shared/usePresence.ts new file mode 100644 index 000000000..d767321ae --- /dev/null +++ b/client/src/components/shared/usePresence.ts @@ -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(value: T, open: boolean): T { + const last = useRef(value); + if (open) last.current = value; + return open ? value : last.current; +} diff --git a/client/src/index.css b/client/src/index.css index 2bf9bee9b..83f99e9aa 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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;