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
+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"));
});
});