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
9 changes: 6 additions & 3 deletions webapp/e2e/smart-playlists-and-podcasts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,14 +583,17 @@ test("subscribes to a podcast, downloads, plays and keeps the place", async ({
);
await page.getByLabel("Feed address").fill("https://feeds.example.org/night.xml");
await page.getByRole("button", { name: "Subscribe" }).click();
expect(podcastWrites).toEqual(["subscribe https://feeds.example.org/night.xml"]);
// The route sees the write after the click returns, not before it: polled.
await expect
.poll(() => podcastWrites)
.toEqual(["subscribe https://feeds.example.org/night.xml"]);

const episodes = page.locator(".podcast-episodes");
await expect(episodes.getByRole("heading", { name: "Night Shift" })).toBeVisible();
const first = episodes.locator("li").filter({ hasText: "First night" });
await expect(first).toContainText("not downloaded");
await first.getByRole("button", { name: "Download" }).click();
expect(podcastWrites).toContain("download episode-1");
await expect.poll(() => podcastWrites).toContain("download episode-1");
await expect(first).toContainText("ready");

// The saved place is shown, and playing resumes from it.
Expand All @@ -604,7 +607,7 @@ test("subscribes to a podcast, downloads, plays and keeps the place", async ({
await expect.poll(() => bookmarkWrites.map((write) => write.id)).toContain("episode-1");

await page.getByRole("button", { name: "Refresh every feed" }).click();
expect(podcastWrites).toContain("refresh");
await expect.poll(() => podcastWrites).toContain("refresh");
});

/**
Expand Down
6 changes: 6 additions & 0 deletions webapp/e2e/studio-nocturne.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7436,6 +7436,12 @@ test("lists what needs attention, splits a credit on the second click, and undoe
await expect(confirm).toBeVisible();
expect(state.writes).toEqual([]);
await expectNoWcagViolations(page);
// The confirmation stands for four seconds, and on a loaded machine the scan
// alone can outlast them: armed again if it lapsed, so the click below meets
// the confirmation rather than racing its timer.
if (!(await confirm.isVisible())) {
await page.getByRole("button", { name: "Split", exact: true }).click();
}
await confirm.click();
await expect(
page.getByText("Ashreveal, Ashish Swargiary is split."),
Expand Down
229 changes: 49 additions & 180 deletions webapp/src/components/action-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,27 @@
import {
type KeyboardEvent,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useId, useRef, useState } from "react";
import { createPortal } from "react-dom";

import { Icon } from "./icons";
import { PopupMenu, type PopupMenuItem } from "./popup-menu";

export type ActionMenuItem = {
key: string;
label: string;
run: () => void;
/** Drawn in the danger colour: the entry that destroys something. */
danger?: boolean;
};
export type ActionMenuItem = Pick<
PopupMenuItem,
"key" | "label" | "run" | "danger"
>;

/**
* A "⋯" button and the short menu it opens: the actions on one card that do
* not deserve a button of their own.
*
* Drawn as the track menu is — the same panel and entries, the same keys of
* the ARIA menu pattern — but anchored to its button rather than to a pointer.
* Portalled to the body and fixed to the viewport for the track menu's reason:
* a page block rising into place carries a `translate`, and a fixed child of
* it would be placed inside the block instead of beside the button.
* The menu is the track menu's own [`PopupMenu`], hung under the button by its
* right edge, and above it when there is no room below. Portalled to the body
* for the track menu's reason: a page block rising into place carries a
* `translate`, and a fixed child of it would be placed inside the block
* instead of beside the button.
*
* Closing hands focus back to the button, before the chosen entry runs.
* Closing by a key or an entry hands focus back to the button, before the
* chosen entry runs: an entry that opens a dialog then finds it there to hand
* back when the dialog closes.
*/
export function ActionMenu({
label,
Expand All @@ -40,12 +33,24 @@ export function ActionMenu({
items: ActionMenuItem[];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [at, setAt] = useState<{ x: number; y: number; above: number } | null>(
null,
);
const button = useRef<HTMLButtonElement>(null);
const menuId = useId();

function toggle() {
if (at) {
setAt(null);
return;
}
const box = button.current?.getBoundingClientRect();
if (!box) return;
setAt({ x: box.right, y: box.bottom + 4, above: box.top - 4 });
}

function close(refocus: boolean) {
setOpen(false);
setAt(null);
if (refocus) button.current?.focus();
}

Expand All @@ -57,175 +62,39 @@ export function ActionMenu({
className="action-menu-button"
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-expanded={at !== null}
aria-controls={at ? menuId : undefined}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onClick={toggle}
>
<Icon name="more" size={18} />
</button>
{open && button.current
{at
? createPortal(
<ActionMenuPanel
<PopupMenu
id={menuId}
className="action-menu"
label={label}
anchor={button.current}
items={items}
x={at.x}
y={at.y}
above={at.above}
align="end"
// Each entry closes the menu itself, focus first, and only then
// runs: `opensDialog` tells the menu not to close it again.
items={items.map((item) => ({
...item,
opensDialog: true,
run: () => {
close(true);
item.run();
},
}))}
onClose={close}
anchor={button}
/>,
document.body,
)
: null}
</>
);
}

function ActionMenuPanel({
id,
label,
anchor,
items,
onClose,
}: {
id: string;
label: string;
anchor: HTMLElement;
items: ActionMenuItem[];
onClose: (refocus: boolean) => void;
}) {
const list = useRef<HTMLDivElement>(null);
const [place, setPlace] = useState<{ left: number; top: number } | null>(
null,
);

// Under the button, its right edge on the button's, and moved back inside
// the window when that would hang off an edge — above the button when there
// is no room below it.
useLayoutEffect(() => {
const node = list.current;
if (!node) return;
const from = anchor.getBoundingClientRect();
const rect = node.getBoundingClientRect();
const margin = 8;
const below = from.bottom + 4;
const top =
below + rect.height + margin > window.innerHeight
? from.top - rect.height - 4
: below;
setPlace({
left: Math.max(
margin,
Math.min(
from.right - rect.width,
window.innerWidth - rect.width - margin,
),
),
top: Math.max(margin, top),
});
}, [anchor]);

// Focus moves in once the menu is placed: hidden for its measurement, it
// could not take focus before.
const placed = place !== null;
useEffect(() => {
if (placed) {
list.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}
}, [placed]);

// Anything outside closes it, as it closes the track menu; a press on the
// button is left to the button, whose click toggles it shut.
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
const away = (event: Event) => {
const target = event.target as Node;
if (list.current?.contains(target) || anchor.contains(target)) return;
closeRef.current(false);
};
const leave = () => closeRef.current(false);
document.addEventListener("pointerdown", away, true);
document.addEventListener("wheel", leave, { capture: true, passive: true });
window.addEventListener("blur", leave);
window.addEventListener("resize", leave);
return () => {
document.removeEventListener("pointerdown", away, true);
document.removeEventListener("wheel", leave, true);
window.removeEventListener("blur", leave);
window.removeEventListener("resize", leave);
};
}, [anchor]);

function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const entries = Array.from(
list.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [],
);
const at = entries.indexOf(document.activeElement as HTMLElement);
const go = (index: number) => {
event.preventDefault();
entries[(index + entries.length) % entries.length]?.focus();
};
switch (event.key) {
case "ArrowDown":
go(at + 1);
break;
case "ArrowUp":
go(at - 1);
break;
case "Home":
go(0);
break;
case "End":
go(entries.length - 1);
break;
case "Escape":
event.preventDefault();
onClose(true);
break;
// Focus goes back to the button first and the Tab is left to run from
// there, so it lands on what follows the button (or, with Shift, what
// precedes it). Left in the menu, which sits at the end of the body,
// the Tab ran off the page and the menu's unmounting dropped focus on
// the body.
case "Tab":
onClose(true);
break;
}
}

return (
<div
ref={list}
id={id}
className="track-menu action-menu"
role="menu"
aria-label={label}
// Measured hidden on the first frame, then placed: shown at the window's
// corner for a frame, it would flash there before moving.
style={
place
? { left: place.left, top: place.top }
: { left: 0, top: 0, visibility: "hidden" }
}
onKeyDown={onKeyDown}
>
{items.map((item) => (
<button
key={item.key}
type="button"
className={`track-menu-item${item.danger ? " danger" : ""}`}
role="menuitem"
tabIndex={-1}
onClick={() => {
// Focus goes back to the button first, so an entry that opens a
// dialog finds it there to hand back when the dialog closes.
onClose(true);
item.run();
}}
>
<span>{item.label}</span>
</button>
))}
</div>
);
}
4 changes: 2 additions & 2 deletions webapp/src/components/canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ReactNode, useEffect, useState } from "react";

import { canvasUrl } from "../api";
import { onMediaChange } from "../lib/media-query";
import type { TranslationKey } from "../state/i18n";

/**
Expand Down Expand Up @@ -41,8 +42,7 @@ export function usePrefersReducedMotion(): boolean {
if (!query) return;
const onChange = () => setReduced(query.matches);
onChange();
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
return onMediaChange(query, onChange);
}, []);
return reduced;
}
Expand Down
Loading
Loading