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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user