Files
siftlode/frontend/src/modules/playlists/SelectionBand.tsx
T
peter 1a1e9d9542 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.
2026-11-02 03:41:08 +01:00

135 lines
4.7 KiB
TypeScript

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";
/** The playlist's second toolbar row while rows are selected: how many, and what can be done to them.
* `count` is the rows shown, what an action covers; `hidden` the selected ones a filter hides, which
* 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 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,
hidden,
recentName,
onMoveRecent,
moveMenuProps,
moveMenuOpen,
onRemove,
onClear,
onClearView,
trailing,
}: {
ref: Ref<HTMLDivElement>;
count: number;
hidden: number;
/** The list the last move went to: one click away, absent when there is none to repeat. */
recentName: string | null;
onMoveRecent: () => void;
moveMenuProps: Record<string, unknown>;
moveMenuOpen: boolean;
onRemove: () => void;
onClear: () => void;
/** The view's reset, which the row this one replaces offers. */
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 className="text-xs whitespace-nowrap">
<span className="sr-only font-medium sm:not-sr-only">
{t("playlists.selectedCount", { count })}
</span>
<span
aria-hidden="true"
title={t("playlists.selectedCount", { count })}
className="font-medium sm:hidden"
>
{count}
</span>
{hidden > 0 && (
<span className="text-muted">
<span aria-hidden="true" className="hidden xl:inline">
{" · "}
</span>
<span className="sr-only xl:not-sr-only">
{t("playlists.selectedHidden", { count: hidden })}
</span>
<span
aria-hidden="true"
title={t("playlists.selectedHidden", { count: hidden })}
className="ml-1 xl:hidden"
>
{t("playlists.selectedHiddenShort", { n: hidden })}
</span>
</span>
)}
</span>
{recentName != null && (
<Button
variant="outline"
size="xs"
disabled={none}
onClick={onMoveRecent}
label={t("playlists.moveToRecent", { name: recentName })}
icon={<History className="w-3.5 h-3.5" />}
>
<span className="hidden max-w-48 truncate xl:inline">
{t("playlists.moveToRecent", { name: recentName })}
</span>
</Button>
)}
<Button
variant="outline"
size="xs"
disabled={none}
aria-expanded={moveMenuOpen}
label={t("playlists.moveSelected")}
icon={<FolderInput className="w-3.5 h-3.5" />}
{...moveMenuProps}
>
<span className="hidden xl:inline">{t("playlists.moveSelected")}</span>
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</Button>
<Button
variant="outline-danger"
size="xs"
disabled={none}
onClick={onRemove}
label={t("playlists.removeSelected")}
icon={<X className="w-3.5 h-3.5" />}
>
<span className="hidden xl:inline">{t("playlists.removeSelected")}</span>
</Button>
<Button
variant="ghost"
size="xs"
onClick={onClear}
label={t("playlists.clearSelection")}
icon={<Square className="w-3.5 h-3.5 xl:hidden" />}
className="xl:underline xl:underline-offset-2"
>
<span className="hidden xl:inline">{t("playlists.clearSelection")}</span>
</Button>
{onClearView && (
<Button
variant="ghost"
size="xs"
onClick={onClearView}
label={t("playlists.clearView")}
icon={<ListRestart className="w-3.5 h-3.5 xl:hidden" />}
className="xl:underline xl:underline-offset-2"
>
<span className="hidden xl:inline">{t("playlists.clearView")}</span>
</Button>
)}
<div className="ml-auto hidden sm:block">{trailing}</div>
</div>
);
}