diff --git a/desktop/README.md b/desktop/README.md index 08b9b79..3d69e92 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -24,7 +24,9 @@ pnpm test:e2e # Playwright Electron e2e (needs a display: `xvfb-run -a pnp # headless Linux box; run `pnpm build` first). e2e/*.spec.ts except native.spec.ts # run against the FakeCore (a11y.spec: axe over every view in both themes; # keyboard-a11y.spec: keyboard-only walkthrough; motion-contrast.spec: reduced - # motion, measured contrast, forced colours, 200% zoom); + # motion, measured contrast, forced colours, 200% zoom; exit-motion.spec: + # closed dialogs, menus, popovers, toasts and the fullscreen player animate + # out, inert, before they unmount, and go at once under reduced motion); # e2e/native.spec.ts runs the REAL core (native addon, # skipped when native/ isn't built) against e2e/fake-navidrome.ts, a small # Subsonic JSON server: setup → sync → play → queue → rate → undo → lyrics → diff --git a/desktop/e2e/exit-motion.spec.ts b/desktop/e2e/exit-motion.spec.ts new file mode 100644 index 0000000..c172b6a --- /dev/null +++ b/desktop/e2e/exit-motion.spec.ts @@ -0,0 +1,106 @@ +// Closed surfaces animate out: a dialog, menu, popover, toast or the fullscreen +// player stays mounted for one exit animation, inert and untouchable, then +// unmounts (lib/presence.ts). Under reduced motion it goes at once. The exits +// are slowed here so the closing state is observable. +import type { Locator, Page } from "@playwright/test"; +import { completeSetup, expect, playFirstAlbum, test } from "./fixtures"; + +/** Stretch every exit so the test can look at it mid-flight. */ +async function slowExits(page: Page): Promise { + await page.evaluate(() => { + document.documentElement.style.setProperty("--dur-exit", "1500ms"); + document.documentElement.style.setProperty("--dur-short", "1500ms"); + }); +} + +/** The element's closing state: the class, inertness, and an animation of its own running. */ +async function exitState(el: Locator): Promise<{ closing: boolean; inert: boolean; animating: boolean }> { + return el.evaluate((n) => ({ + closing: n.classList.contains("closing"), + inert: n.hasAttribute("inert"), + animating: n.getAnimations().some((a) => a.playState === "running"), + })); +} + +async function expectExiting(el: Locator): Promise { + await expect.poll(() => exitState(el)).toEqual({ closing: true, inert: true, animating: true }); + await expect(el).toHaveCount(0); +} + +test.describe("exit animations", () => { + test("menus, dialogs, the palette, popovers, toasts and the fullscreen player leave through an exit animation", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + await slowExits(page); + + // A toast, first while the one from playing is fresh (it auto-dismisses + // after 5 s; hovering holds it): dismissed by its action, it sinks away + // rather than vanishing. + const toast = page.getByTestId("toast").filter({ hasText: "Play" }).first(); + await expect(toast).toBeVisible(); + await toast.hover(); + await toast.getByTestId("toast-action").click(); + await expect.poll(() => exitState(toast)).toEqual({ closing: true, inert: true, animating: true }); + await expect(page.locator('[data-testid="toast"].closing')).toHaveCount(0); + + // Context menu: Escape starts the exit; it is inert at once and then gone. + await page.getByTestId("track-row").first().click({ button: "right" }); + const menu = page.getByTestId("context-menu"); + await expect(menu).toBeVisible(); + await page.keyboard.press("Escape"); + await expectExiting(menu); + + // Dialog: the sheet and its scrim leave together; the shell is live again from the first frame. + await page.getByTestId("track-row").first().click({ button: "right" }); + await menu.locator('[data-action="addToPlaylist"]').click(); + const dialog = page.getByTestId("dialog-addToPlaylist"); + await expect(dialog).toBeVisible(); + expect(await page.getByTestId("app-body").getAttribute("inert")).not.toBeNull(); + await page.keyboard.press("Escape"); + await expect.poll(() => exitState(dialog)).toEqual({ closing: true, inert: true, animating: true }); + expect(await page.locator(".overlay").evaluate((n) => n.classList.contains("closing") && n.hasAttribute("inert"))).toBe(true); + expect(await page.getByTestId("app-body").getAttribute("inert")).toBeNull(); + await expect(dialog).toHaveCount(0); + + // Command palette. + await page.getByTestId("content").click(); + await page.keyboard.press("Control+k"); + const palette = page.getByTestId("palette"); + await expect(palette).toBeVisible(); + await page.keyboard.press("Escape"); + await expectExiting(palette); + + // Jobs popover. + await page.getByTestId("jobs-button").click(); + const popover = page.getByTestId("jobs-popover"); + await expect(popover).toBeVisible(); + await page.getByTestId("jobs-button").click(); + await expectExiting(popover); + + // The fullscreen player: fades out, focus already back in the shell. + await page.getByTestId("content").click(); + await page.keyboard.press("f"); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs).toBeVisible(); + await page.keyboard.press("Escape"); + await expect.poll(() => exitState(fs)).toEqual({ closing: true, inert: true, animating: true }); + expect(await page.evaluate(() => !document.activeElement?.closest('[data-testid="fullscreen-player"]'))).toBe(true); + await expect(fs).toHaveCount(0); + }); + + test("under reduced motion a closed surface goes at once", async ({ hocket }) => { + const { page } = hocket; + await page.emulateMedia({ reducedMotion: "reduce" }); + await completeSetup(page); + await slowExits(page); + await page.getByTestId("content").click(); + await page.keyboard.press("Control+k"); + const palette = page.getByTestId("palette"); + await expect(palette).toBeVisible(); + await page.keyboard.press("Escape"); + // Well inside the stretched exit: nothing waited for an animation that never ran. + await expect(palette).toHaveCount(0, { timeout: 500 }); + expect(await page.evaluate(() => document.getAnimations().filter((a) => a.playState === "running").length)).toBe(0); + }); +}); diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index a792735..ada692e 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -32,7 +32,6 @@ export function App() { const ready = useApp((s) => s.ready); // The core probes before installing: ServersChanged only arrives once a server is real. const hasServer = useApp((s) => s.servers.length > 0); - const fullscreen = useApp((s) => s.fullscreen); const coreKind = useApp((s) => s.meta?.coreKind); const network = useApp((s) => s.network); const panels = useApp((s) => s.panels); @@ -68,7 +67,7 @@ export function App() { {/* Covers the whole window (sidebar, panels and player bar included). */} - {fullscreen ? : null} + diff --git a/desktop/src/renderer/components/CommandPalette.tsx b/desktop/src/renderer/components/CommandPalette.tsx index 547e8a6..85d096b 100644 --- a/desktop/src/renderer/components/CommandPalette.tsx +++ b/desktop/src/renderer/components/CommandPalette.tsx @@ -10,6 +10,7 @@ import { formatChord, parseChord } from "../store/shortcuts"; import { Artwork } from "./Artwork"; import { Icon, hasIcon } from "./Icon"; import { trapTab, useReturnFocus } from "../lib/focus"; +import { usePresence } from "../lib/presence"; interface Item extends Rankable { icon?: string; @@ -31,6 +32,8 @@ export function CommandPalette() { const [active, setActive] = useState(0); const input = useRef(null); const seq = useRef(0); + // The closed palette stays mounted, inert, while its exit plays. + const { value: shown, closing, motion, exitProps } = usePresence(open || undefined); useReturnFocus(open); useEffect(() => { @@ -76,7 +79,7 @@ export function CommandPalette() { if (open) document.getElementById(`pal-opt-${active}`)?.scrollIntoView({ block: "nearest" }); }, [active, open]); - if (!open) return null; + if (!shown) return null; const run = (i: Item) => { setOpen(false); i.run(); @@ -91,8 +94,8 @@ export function CommandPalette() { }; const firstAction = ranked.findIndex((r) => r.item.kind === "action"); return ( -
e.target === e.currentTarget && setOpen(false)}> -
+
e.target === e.currentTarget && setOpen(false)}> +
setQuery(e.target.value)} role="combobox" aria-label={t("action.palette")} aria-autocomplete="list" aria-expanded={ranked.length > 0} aria-controls="palette-list" aria-activedescendant={ranked[active] ? `pal-opt-${active}` : undefined} data-testid="palette-input" /> {ranked.length === 0 ?
{t("palette.empty")}
: null}
diff --git a/desktop/src/renderer/components/ContextMenu.tsx b/desktop/src/renderer/components/ContextMenu.tsx index c2668a3..3b244c6 100644 --- a/desktop/src/renderer/components/ContextMenu.tsx +++ b/desktop/src/renderer/components/ContextMenu.tsx @@ -12,6 +12,7 @@ import { useApp } from "../store/app"; import { executeAction, type ActionContext } from "../store/actions"; import { Icon, hasIcon } from "./Icon"; import { handOnFocus } from "../lib/focus"; +import { usePresence } from "../lib/presence"; /** Where focus goes back to when the menu closes. */ let returnFocus: HTMLElement | null = null; @@ -54,31 +55,34 @@ export function openContextMenuFromKeyboard(): boolean { } export function ContextMenu() { - const menu = useApp((s) => s.contextMenu); + const live = useApp((s) => s.contextMenu); const close = useApp((s) => s.closeContextMenu); const shortcuts = useApp((s) => s.shortcuts); const platform = useApp((s) => s.meta?.platform ?? "linux"); const ref = useRef(null); + // The closed menu stays mounted, inert, while its exit plays. Everything + // below that positions, focuses or listens is keyed on the live state. + const { value: menu, motion, exitProps } = usePresence(live, ref); const [pos, setPos] = useState({ x: 0, y: 0 }); const [active, setActive] = useState(-1); const actions = menu?.actions ?? []; useLayoutEffect(() => { - if (!menu || !ref.current) return; + if (!live || !ref.current) return; // Layout size, not getBoundingClientRect(): the menu opens scaled down by its entrance animation. const r = { width: ref.current.offsetWidth, height: ref.current.offsetHeight }; - const x = Math.min(menu.x, window.innerWidth - r.width - 6); - const y = Math.min(menu.y, window.innerHeight - r.height - 6); + const x = Math.min(live.x, window.innerWidth - r.width - 6); + const y = Math.min(live.y, window.innerHeight - r.height - 6); setPos({ x: Math.max(4, x), y: Math.max(4, y) }); // Keyboard-opened menus start on the first enabled item, like native menus. - setActive(menu.keyboard ? menu.actions.findIndex((a) => a.enabled) : -1); + setActive(live.keyboard ? live.actions.findIndex((a) => a.enabled) : -1); ref.current.focus(); - }, [menu]); + }, [live]); // Focus goes back to the trigger when the menu closes (unless an action moved it on purpose). const wasOpen = useRef(false); useEffect(() => { - if (menu) { wasOpen.current = true; return; } + if (live) { wasOpen.current = true; return; } if (!wasOpen.current) return; wasOpen.current = false; const el = returnFocus; @@ -89,10 +93,10 @@ export function ContextMenu() { if (useApp.getState().dialog || useApp.getState().paletteOpen) return; handOnFocus(null); if (el && el.isConnected && focusLost) el.focus({ preventScroll: true }); - }, [menu]); + }, [live]); useEffect(() => { - if (!menu) return; + if (!live) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) close(); }; @@ -105,7 +109,7 @@ export function ContextMenu() { window.removeEventListener("blur", onBlur); window.removeEventListener("resize", onBlur); }; - }, [menu, close]); + }, [live, close]); if (!menu) return null; const run = (a: ActionDescriptor) => { @@ -152,7 +156,7 @@ export function ContextMenu() { }; let lastCategory: string | undefined; return ( -
= 0 ? `cm-item-${active}` : undefined} tabIndex={-1} style={{ left: pos.x, top: pos.y }} onKeyDown={onKey} data-testid="context-menu"> +
= 0 ? `cm-item-${active}` : undefined} tabIndex={-1} style={{ left: pos.x, top: pos.y }} onKeyDown={onKey} data-testid="context-menu"> {actions.map((a, i) => { const sep = lastCategory !== undefined && lastCategory !== a.category; lastCategory = a.category; diff --git a/desktop/src/renderer/components/Dialogs.tsx b/desktop/src/renderer/components/Dialogs.tsx index 282daa5..0a3f78f 100644 --- a/desktop/src/renderer/components/Dialogs.tsx +++ b/desktop/src/renderer/components/Dialogs.tsx @@ -9,21 +9,25 @@ import { fmtBytes, fmtDate, fmtTime } from "../lib/format"; import { Artwork } from "./Artwork"; import { Icon } from "./Icon"; import { trapTab, useReturnFocus } from "../lib/focus"; +import { usePresence } from "../lib/presence"; export function Dialogs() { - const dialog = useApp((s) => s.dialog); + const live = useApp((s) => s.dialog); const close = useApp((s) => s.closeDialog); const ref = useRef(null); - useReturnFocus(!!dialog); + // The closed dialog stays mounted, inert, while its exit plays; focus goes + // back and the Connect picker closes on the live state, not the animation. + const { value: dialog, closing, motion, exitProps } = usePresence(live, ref); + useReturnFocus(!!live); useEffect(() => { - if (!dialog) return; + if (!live) return; // Initial focus: the field, else the safe (non-destructive) button, else the first control. const root = ref.current; const first = root?.querySelector("input, textarea, select") ?? root?.querySelector("[data-autofocus]") ?? root?.querySelector("button, [tabindex='0']"); first?.focus(); - if (dialog.kind === "connect") return () => bridge().dispatch({ type: "closeHandoffPicker" }); + if (live.kind === "connect") return () => bridge().dispatch({ type: "closeHandoffPicker" }); return undefined; - }, [dialog]); + }, [live]); if (!dialog) return null; const onKey = (e: React.KeyboardEvent) => { e.stopPropagation(); @@ -35,8 +39,8 @@ export function Dialogs() { trapTab(e); }; return ( -
e.target === e.currentTarget && close()}> -
+
e.target === e.currentTarget && close()}> +
{dialog.kind === "prompt" ? : null} {dialog.kind === "confirm" ? : null} {dialog.kind === "addToPlaylist" ? : null} diff --git a/desktop/src/renderer/components/RightPanel.tsx b/desktop/src/renderer/components/RightPanel.tsx index b7862e5..fcf042f 100644 --- a/desktop/src/renderer/components/RightPanel.tsx +++ b/desktop/src/renderer/components/RightPanel.tsx @@ -12,6 +12,7 @@ import { Tabs, tabPanelProps } from "./Tabs"; import { bridge } from "../core/bridge"; import { SK } from "@shared/settings-keys"; import { NARROW, useMediaQuery } from "../lib/media"; +import { usePresence } from "../lib/presence"; export function RightPanel() { const panels = useApp((s) => s.panels); @@ -46,13 +47,17 @@ export function RightPanel() { const narrow = useMediaQuery(NARROW); const drawerOpen = useApp((s) => s.drawerOpen); const setDrawerOpen = useApp((s) => s.setDrawerOpen); - if (narrow ? !drawerOpen : !panels.rightOpen) return null; + // The drawer withdraws before it unmounts; the docked panel is part of the + // layout (the content pane widens the moment it closes), so it just goes. + const open = narrow ? drawerOpen : panels.rightOpen; + const presence = usePresence(open || undefined, ref, { instant: !narrow }); + if (!presence.value) return null; const qc = panels.queueCollapsed; const lc = panels.lyricsCollapsed; const queueFlex = qc ? "0 0 32px" : lc ? "1 1 auto" : `${panels.splitRatio} 1 0`; const lyricsFlex = lc ? "0 0 32px" : qc ? "1 1 auto" : `${1 - panels.splitRatio} 1 0`; return ( -