Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
106 changes: 106 additions & 0 deletions desktop/e2e/exit-motion.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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);
});
});
3 changes: 1 addition & 2 deletions desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -68,7 +67,7 @@ export function App() {
<PlayerBar inert={modal} />
<NowPlayingAnnouncer />
{/* Covers the whole window (sidebar, panels and player bar included). */}
{fullscreen ? <FullscreenPlayer /> : null}
<FullscreenPlayer />
<ContextMenu />
<CommandPalette />
<Dialogs />
Expand Down
9 changes: 6 additions & 3 deletions desktop/src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,8 @@ export function CommandPalette() {
const [active, setActive] = useState(0);
const input = useRef<HTMLInputElement>(null);
const seq = useRef(0);
// The closed palette stays mounted, inert, while its exit plays.
const { value: shown, closing, motion, exitProps } = usePresence<true, HTMLDivElement>(open || undefined);
useReturnFocus(open);

useEffect(() => {
Expand Down Expand Up @@ -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();
Expand All @@ -91,8 +94,8 @@ export function CommandPalette() {
};
const firstAction = ranked.findIndex((r) => r.item.kind === "action");
return (
<div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)}>
<div className="palette fade-in" role="dialog" aria-modal="true" aria-label={t("action.palette")} onKeyDown={onKey} data-testid="palette">
<div className={`overlay ${closing ? "closing" : ""}`} inert={closing} onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)}>
<div {...exitProps} className={`palette ${motion}`} role="dialog" aria-modal="true" aria-label={t("action.palette")} onKeyDown={onKey} data-testid="palette">
<input ref={input} className="input" placeholder={t("palette.placeholder")} value={query} onChange={(e) => 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 ? <div className="group" role="status">{t("palette.empty")}</div> : null}
<div className="list" id="palette-list" role="listbox" aria-label={t("a11y.paletteResults")}>
Expand Down
26 changes: 15 additions & 11 deletions desktop/src/renderer/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLDivElement>(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;
Expand All @@ -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();
};
Expand All @@ -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) => {
Expand Down Expand Up @@ -152,7 +156,7 @@ export function ContextMenu() {
};
let lastCategory: string | undefined;
return (
<div ref={ref} className="menu fade-in" role="menu" aria-activedescendant={active >= 0 ? `cm-item-${active}` : undefined} tabIndex={-1} style={{ left: pos.x, top: pos.y }} onKeyDown={onKey} data-testid="context-menu">
<div {...exitProps} className={`menu ${motion}`} role="menu" aria-activedescendant={active >= 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;
Expand Down
18 changes: 11 additions & 7 deletions desktop/src/renderer/components/Dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(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<HTMLElement>("input, textarea, select") ?? root?.querySelector<HTMLElement>("[data-autofocus]") ?? root?.querySelector<HTMLElement>("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<HTMLDivElement>) => {
e.stopPropagation();
Expand All @@ -35,8 +39,8 @@ export function Dialogs() {
trapTab(e);
};
return (
<div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && close()}>
<div ref={ref} className="dialog fade-in" role={dialog.kind === "confirm" ? "alertdialog" : "dialog"} aria-modal="true" aria-labelledby="dialog-title" aria-describedby={dialog.kind === "confirm" ? "dialog-message" : undefined} onKeyDown={onKey} data-testid={`dialog-${dialog.kind}`}>
<div className={`overlay ${closing ? "closing" : ""}`} inert={closing} onMouseDown={(e) => e.target === e.currentTarget && close()}>
<div {...exitProps} className={`dialog ${motion}`} role={dialog.kind === "confirm" ? "alertdialog" : "dialog"} aria-modal="true" aria-labelledby="dialog-title" aria-describedby={dialog.kind === "confirm" ? "dialog-message" : undefined} onKeyDown={onKey} data-testid={`dialog-${dialog.kind}`}>
{dialog.kind === "prompt" ? <Prompt {...dialog} /> : null}
{dialog.kind === "confirm" ? <Confirm {...dialog} /> : null}
{dialog.kind === "addToPlaylist" ? <AddToPlaylist trackIds={dialog.trackIds} /> : null}
Expand Down
9 changes: 7 additions & 2 deletions desktop/src/renderer/components/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<aside ref={ref} id="side-panel" className={`right-panel ${narrow ? "drawer" : ""}`} aria-label={t("a11y.sidePanel")} data-testid="right-panel"
<aside {...presence.exitProps} id="side-panel" className={`right-panel ${narrow ? "drawer" : ""} ${presence.closing ? "closing" : ""}`} aria-label={t("a11y.sidePanel")} data-testid="right-panel"
onKeyDown={narrow ? (e) => { if (e.key === "Escape" && !e.defaultPrevented) { e.preventDefault(); e.stopPropagation(); setDrawerOpen(false); document.querySelector<HTMLElement>('[data-testid="toggle-side-panel"]')?.focus(); } } : undefined}>
<div className="resize-handle" style={{ left: -3, right: "auto" }} onMouseDown={(e) => { e.preventDefault(); setWidthDrag({ x: e.clientX, w: panels.rightWidth }); }} role="separator" aria-orientation="vertical" aria-label={t("a11y.resizeSidePanel")} aria-valuenow={panels.rightWidth} aria-valuemin={260} aria-valuemax={560} tabIndex={0}
onKeyDown={(e) => { const d = e.key === "ArrowLeft" ? 16 : e.key === "ArrowRight" ? -16 : 0; if (!d) return; e.preventDefault(); e.stopPropagation(); setPanels({ rightWidth: Math.max(260, Math.min(560, panels.rightWidth + d)) }); }} />
Expand Down
7 changes: 5 additions & 2 deletions desktop/src/renderer/components/Toasts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { useEffect, useState } from "react";
import type { Command } from "@core/api";
import { useApp } from "../store/app";
import { bridge } from "../core/bridge";
import { usePresenceList } from "../lib/presence";

export function Toasts() {
const toasts = useApp((s) => s.toasts);
const dismiss = useApp((s) => s.dismissToast);
// Hovering or focusing a toast holds every toast (WCAG 2.2.1): an Undo must stay reachable.
const [held, setHeld] = useState(false);
// A dismissed toast stays mounted, inert, while it sinks away.
const entries = usePresenceList(toasts, (t) => t.id);
useEffect(() => {
if (held) return;
const timers = toasts.map((t) => setTimeout(() => dismiss(t.id), Math.max(1500, t.durationMs)));
Expand All @@ -16,8 +19,8 @@ export function Toasts() {
// Always mounted: a live region must exist before its content changes to be announced.
return (
<div className="toasts" role="status" aria-live="polite" data-testid="toasts" onMouseEnter={() => setHeld(true)} onMouseLeave={() => setHeld(false)} onFocus={() => setHeld(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHeld(false); }}>
{toasts.map((t) => (
<div key={t.id} className="toast fade-in" data-testid="toast">
{entries.map(({ item: t, key, motion, exitProps }) => (
<div key={key} {...exitProps} className={`toast ${motion}`} data-testid="toast">
<span>{t.message}</span>
{t.actionLabel && t.actionCommand ? (
<button
Expand Down
Loading
Loading