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.
135 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|