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