feat(design): a playlist's one action list (C1) — the row's "…", Delete + undo, the header's "…" (E28 S3e2)

- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
  remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
  The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
  renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
  offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
  delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
  the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
  fixed widths had left the title no width.
This commit is contained in:
2026-09-26 04:05:39 +02:00
parent aaf1843b09
commit 7455fff401
24 changed files with 1061 additions and 425 deletions
+4 -2
View File
@@ -68,10 +68,12 @@ export const playlistsApi = {
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
deletePlaylist: (id: number, onYoutube = false) =>
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
addToPlaylist: (id: number, videoId: string) =>
// `position` (an index in the stored order) puts it back where a removal took it from — the
// list page's Undo; omitted, it is appended.
addToPlaylist: (id: number, videoId: string, position?: number) =>
req(`/api/playlists/${id}/items`, {
method: "POST",
body: JSON.stringify({ video_id: videoId }),
body: JSON.stringify({ video_id: videoId, ...(position != null && { position }) }),
}),
removeFromPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
+13 -2
View File
@@ -31,13 +31,19 @@ export type KeyScope =
| "page"
| "search"
| "list"
| "listDelete"
| "undo"
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>(["page", "search", "list"]);
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
"page",
"search",
"list",
"listDelete",
]);
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
@@ -132,6 +138,9 @@ export const SHORTCUTS = [
{ id: "up", scope: "list", keys: ["ArrowUp"], labelKey: "keys.gridMove" },
{ id: "down", scope: "list", keys: ["ArrowDown"], labelKey: "keys.gridMove" },
{ id: "open", scope: "list", keys: ["Enter"], labelKey: "keys.open" },
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
@@ -412,7 +421,9 @@ export function useActiveScopes(): KeyScope[] {
// registers `page` in its own effect, which React runs after every child's — so by mount order
// the module jumps would head the sheet, above the list keys of the page you are looking at.
const surfaces = live.filter((s) => !isPageLevelScope(s));
const pageLevel = (["list", "search", "page"] as const).filter((s) => live.includes(s));
const pageLevel = (["list", "listDelete", "search", "page"] as const).filter((s) =>
live.includes(s),
);
return [...surfaces, ...pageLevel, "global"];
}
+53
View File
@@ -562,3 +562,56 @@ describe("useListNav · leaving the list, and naming the item (deep-review E11 r
);
});
});
describe("useListNav · Delete (E28 S3e2: a playlist row's X became the Delete key)", () => {
function Deletable({ onDelete }: { onDelete?: boolean }) {
const [ids, setIds] = useState([0, 1, 2]);
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, {
enabled: true,
onDelete:
onDelete === false ? undefined : (i) => setIds((xs) => xs.filter((_, j) => j !== i)),
});
return (
<div ref={ref}>
{ids.map((id, i) => (
<div key={id} {...navItemAttrs(i)} data-testid={`d${id}`}>
<button data-testid={`d${id}-action`}>action</button>
</div>
))}
</div>
);
}
const gone = (id: string) => screen.queryByTestId(id) === null;
it("takes the focused item out and moves the focus to the one now in its slot", async () => {
render(<Deletable />);
press("j");
press("j");
expect(focused()).toBe("d1");
const e = press("Delete");
expect(e.defaultPrevented).toBe(true);
expect(gone("d1")).toBe(true);
await waitFor(() => expect(focused()).toBe("d2"));
// …so a second Delete takes the next row.
press("Delete");
expect(gone("d2")).toBe(true);
await waitFor(() => expect(focused()).toBe("d0"));
});
it("leaves Delete to a control inside the item", () => {
render(<Deletable />);
act(() => screen.getByTestId("d1-action").focus());
const e = press("Delete");
expect(e.defaultPrevented).toBe(false);
expect(gone("d1")).toBe(false);
});
it("does nothing in a list that cannot remove", () => {
render(<Deletable onDelete={false} />);
press("j");
const e = press("Delete");
expect(e.defaultPrevented).toBe(false);
expect(gone("d0")).toBe(false);
});
});
+16 -4
View File
@@ -16,9 +16,9 @@ function Scopes() {
return <output data-testid="scopes">{useActiveScopes().join(" ")}</output>;
}
function Queue({ count }: { count: number }) {
function Queue({ count, onDelete }: { count: number; onDelete?: (i: number) => void }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
useListNav(ref, { enabled: true, onDelete });
return (
<div ref={ref}>
{count === 0 ? (
@@ -34,10 +34,10 @@ function Queue({ count }: { count: number }) {
);
}
const page = (count: number) => (
const page = (count: number, onDelete?: (i: number) => void) => (
<>
<Scopes />
<Queue count={count} />
<Queue count={count} onDelete={onDelete} />
</>
);
const scopes = () => screen.getByTestId("scopes").textContent!.split(" ");
@@ -51,4 +51,16 @@ describe("useListNav · the list key scope", () => {
rerender(page(0));
await waitFor(() => expect(scopes()).not.toContain("list"));
});
// E28 S3e2: Delete is on the sheet only where a list can take an item out — a playlist's rows, not
// the feed or a Plex grid, where nothing answers it.
it("puts Delete on the sheet only for a list that removes items", async () => {
const { rerender } = render(page(2));
await waitFor(() => expect(scopes()).toContain("list"));
expect(scopes()).not.toContain("listDelete");
rerender(page(2, () => {}));
await waitFor(() => expect(scopes()).toContain("listDelete"));
rerender(page(0, () => {}));
await waitFor(() => expect(scopes()).not.toContain("listDelete"));
});
});
+44 -1
View File
@@ -33,6 +33,8 @@ interface NavList {
readonly count: () => number;
readonly cols: () => number;
readonly reveal: (index: number) => void;
/** Takes the item at `index` out of the list (Delete), or null for a list that cannot. */
readonly remove: () => ((index: number) => void) | null;
}
const lists = new Map<HTMLElement, NavList>();
@@ -316,6 +318,38 @@ function onKey(e: KeyboardEvent): void {
target.click();
},
});
dispatch(e, "listDelete", {
// On the item root, like Enter — and only in a list that registered a remover.
remove: (ev) => {
const cur = current();
const remove = cur?.list.remove();
if (!cur || !remove || document.activeElement !== cur.item) return;
ev.preventDefault();
remove(cur.index);
refocusSlot(cur.list, cur.index, cur.item);
},
});
}
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
const REMOVE_FRAMES = 30;
/** After the focused item was removed, focus the item now in its slot (the next one; the new last
* when it was the last), so a second Delete takes the next row — the way a mail list behaves. Only
* while nothing else took the focus meanwhile: the removal fell back to <body>, nothing more. */
function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
let frames = 0;
const wait = () => {
if (removed.isConnected) {
if (++frames < REMOVE_FRAMES) requestAnimationFrame(wait);
return;
}
const active = document.activeElement;
if (active && active !== document.body && !isStandInFocus(active)) return;
const n = list.count();
if (n > 0) focusIndex(list, Math.min(index, n - 1));
};
requestAnimationFrame(wait);
}
// ── The item a view was left from ────────────────────────────────────────────────────────────────
@@ -417,7 +451,14 @@ export function restoreNavPosition(pos: NavPosition): () => void {
* the count and the columns are then measured from the DOM at the moment a key is pressed. */
export function useListNav(
ref: RefObject<HTMLElement | null>,
opts: { enabled: boolean; count?: number; cols?: number; reveal?: (index: number) => void },
opts: {
enabled: boolean;
count?: number;
cols?: number;
reveal?: (index: number) => void;
/** A list whose items the Delete key takes out (a playlist's rows): the item's index. */
onDelete?: (index: number) => void;
},
): void {
const optsRef = useRef(opts);
optsRef.current = opts;
@@ -426,6 +467,7 @@ export function useListNav(
// arrows and Enter over nothing (keymap: a scope is pushed only while its keys really do something).
const [hasItems, setHasItems] = useState(false);
useKeyScope("list", opts.enabled && hasItems);
useKeyScope("listDelete", opts.enabled && hasItems && !!opts.onDelete);
useEffect(() => {
const el = ref.current;
if (!opts.enabled || !el) return;
@@ -434,6 +476,7 @@ export function useListNav(
count: () => optsRef.current.count ?? ownItems(el).length,
cols: () => optsRef.current.cols ?? measuredCols(el),
reveal: (i) => optsRef.current.reveal?.(i),
remove: () => optsRef.current.onDelete ?? null,
});
el.setAttribute(LIST, "");
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
+3
View File
@@ -37,6 +37,9 @@ export interface Column<T> {
cardPrimary?: boolean;
hideInCard?: boolean;
cardLabel?: boolean;
// A column a table WITHOUT the card fallback (the playlist's drag-reorderable hybrid) leaves out
// below `sm`: on a phone its fixed widths alone overran the screen and left the title no width.
phoneHidden?: boolean;
}
export type FilterMap = Record<string, string | string[]>;