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