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;