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