fix(playlists): what the first review of bulk actions found

An Esc during a pointer drag is spent on the drag and leaves the selection.
A wheel over the rail re-reads the target each frame, so a drop lands on the
row under the pointer. After a bulk removal the focus goes to the next row
left, and to the list's menu when the list is emptied.

remove-bulk names the videos that went, and its Undo brings back only those.
An Undo of a move keeps each row (and when it was saved), leaves out a video
that moved on from the target, closes up its place, and says how many came
back. Delete with every tick hidden removes nothing and says why. A list gone
before the lock answers 404, and a body without its videos 422.

One removal path now: the single row goes through the bulk one, the same
toast and undo, which also retires the add-at-position route. railDrop reuses
the store and scrollParent, the drag image the overlay and the z-index token,
and the sensors keep their number. The count is read out from a region that
outlives the band. Tests fill the gaps the review named, each seen failing.
This commit is contained in:
2026-11-02 03:41:08 +01:00
parent d09b5d6abb
commit 1a1e9d9542
24 changed files with 1168 additions and 379 deletions
@@ -1,4 +1,4 @@
import type { ReactNode, Ref } from "react";
import type { MouseEventHandler, ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderInput, History, ListRestart, Square, X } from "lucide-react";
import { Button } from "../../components/ui/Button";
@@ -8,7 +8,8 @@ import { Button } from "../../components/ui/Button";
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on.
* Below `xl` the buttons are glyphs named in their tooltips: in words the row wraps there, and the
* first tick would push the list from under the second tap. On a phone the count is its number and
* the undo pair stays out, for the same one row. */
* the undo pair stays out, for the same one row. The page says the count out loud: this band goes
* when the selection does, and a live region that leaves with its news announces nothing. */
export function SelectionBand({
ref,
count,
@@ -33,14 +34,14 @@ export function SelectionBand({
onRemove: () => void;
onClear: () => void;
/** The view's reset, which the row this one replaces offers. */
onClearView?: () => void;
onClearView?: MouseEventHandler<HTMLButtonElement>;
trailing: ReactNode;
}) {
const { t } = useTranslation();
const none = count === 0;
return (
<div ref={ref} className="flex items-center gap-1 mt-1 flex-wrap xl:gap-2">
<span role="status" className="text-xs whitespace-nowrap">
<span className="text-xs whitespace-nowrap">
<span className="sr-only font-medium sm:not-sr-only">
{t("playlists.selectedCount", { count })}
</span>