fix(playlists): the selection band keeps to one row on a tablet, and a tick moves no row

Below xl the band's buttons are glyphs named in their tooltips, and the row it
replaces stays in the same grid cell, invisible, so the first tick leaves the
list where the second tap expects it. The test's row menu asked for the pins
unmocked, and the offline toast could land before the check: mocked.
This commit is contained in:
2026-11-01 22:44:32 +01:00
parent efbbb23525
commit bf54a3ce5f
6 changed files with 144 additions and 44 deletions
+3
View File
@@ -544,6 +544,8 @@ of a full-width panel and nothing on the right.
the count (and how many a filter hides), the last move's target as one click, Move to, Remove, Clear
selection. An action clears the selection, undoes by its toast and, when it fails, ticks its rows again.
Delete takes the selection when there is one, else the row under the keyboard, with no confirm.
Below `xl` the band's buttons are glyphs named in their tooltips, and a filter's hidden count is "+N" with
its sentence in the tooltip. The row the band replaces keeps its height, invisible, so a tick moves no row.
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
@@ -845,3 +847,4 @@ of a full-width panel and nothing on the right.
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. |
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. |
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. |
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. |
@@ -107,6 +107,7 @@
"selectedCount_other": "{{count}} selected",
"selectedHidden_one": "{{count}} more hidden by the filter",
"selectedHidden_other": "{{count}} more hidden by the filter",
"selectedHiddenShort": "+{{n}}",
"clearSelection": "Clear selection",
"moveSelected": "Move to",
"removeSelected": "Remove",
@@ -107,6 +107,7 @@
"selectedCount_other": "{{count}} kijelölve",
"selectedHidden_one": "további {{count}} a szűrő mögött",
"selectedHidden_other": "további {{count}} a szűrő mögött",
"selectedHiddenShort": "+{{n}}",
"clearSelection": "Kijelölés törlése",
"moveSelected": "Áthelyezés ide",
"removeSelected": "Eltávolítás",
@@ -157,6 +157,8 @@ function mount(
vi.spyOn(api, "playlist").mockImplementation(
(detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never,
);
// A row's menu asks for them, and an answer from nowhere would be the last toast.
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] });
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs });
if (listIn)
@@ -1170,13 +1172,13 @@ describe("Playlists — acting on the selected rows", () => {
mount(abc);
await box("Video A");
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
expect(screen.getByText("playlists.groupByChannel")).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "playlists.groupByChannel" })).toBeTruthy();
await tick("Video A");
expect(await screen.findByText("playlists.selectedCount")).toBeTruthy();
expect(screen.queryByText("playlists.groupByChannel")).toBeNull();
expect(screen.queryByRole("checkbox", { name: "playlists.groupByChannel" })).toBeNull();
fireEvent.click(await button("playlists.clearSelection"));
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
expect(await screen.findByText("playlists.groupByChannel")).toBeTruthy();
expect(await screen.findByRole("checkbox", { name: "playlists.groupByChannel" })).toBeTruthy();
});
it("offers the last target as one click, and every other list in the menu", async () => {
@@ -1443,6 +1445,65 @@ describe("Playlists — acting on the selected rows", () => {
expect(((await button("playlists.clearSelection")) as HTMLButtonElement).disabled).toBe(false);
});
describe("on a tablet the band keeps to one row, so the first tick moves no row", () => {
// Laid out only from `xl` up: below it the element is `display: none`.
const onlyWide = (el: Element) =>
el.classList.contains("hidden") && el.classList.contains("xl:inline");
it("keeps the replaced row's height: in the band's cell, out of sight and out of reach", async () => {
mount(abc);
const count = (await screen.findByText(/playlists\.itemCount/)).parentElement!;
expect(count.classList.contains("invisible")).toBe(false);
await tick("Video A");
const band = (await screen.findByText("playlists.selectedCount")).closest("[role=status]")!;
expect(count.classList.contains("invisible")).toBe(true);
expect(count.getAttribute("aria-hidden")).toBe("true");
expect(count.parentElement).toBe(band.parentElement!.parentElement!.parentElement);
expect(count.className).toContain("[grid-area:1/1]");
expect(band.parentElement!.parentElement!.className).toContain("[grid-area:1/1]");
});
it("shows each button's word only on a wide window, and keeps its name and tooltip", async () => {
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
target: { value: "Video" },
});
for (const name of [
"playlists.moveToRecent",
"playlists.moveSelected",
"playlists.removeSelected",
"playlists.clearSelection",
"playlists.clearView",
]) {
const el = await button(name);
expect(el.getAttribute("title"), name).toBe(name);
const word = [...el.querySelectorAll("span")].find((s) => s.textContent === name);
expect(word, name).toBeTruthy();
expect(onlyWide(word!), name).toBe(true);
expect(el.querySelector("svg"), name).toBeTruthy();
}
});
it("keeps the count's words, and shortens the hidden ones with the sentence still read out", async () => {
mount(abc);
await tick("Video A");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
target: { value: "Video B" },
});
const sentence = await screen.findByText("playlists.selectedHidden");
expect(sentence.classList.contains("sr-only")).toBe(true);
expect(sentence.classList.contains("xl:not-sr-only")).toBe(true);
const short = screen.getByText("playlists.selectedHiddenShort");
expect(short.getAttribute("aria-hidden")).toBe("true");
expect(short.getAttribute("title")).toBe("playlists.selectedHidden");
expect(short.classList.contains("xl:hidden")).toBe(true);
expect(onlyWide(screen.getByText("playlists.selectedCount"))).toBe(false);
});
});
it("hands the focus to the header's box when the band goes from under it", async () => {
mount(abc);
await tick("Video A");
+38 -26
View File
@@ -98,6 +98,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import RemoteImage from "../../components/ui/RemoteImage";
import PlaylistsHeader from "./PlaylistsHeader";
import { SelectionBand } from "./SelectionBand";
import clsx from "clsx";
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -1239,32 +1240,43 @@ function PlaylistDetail() {
</div>
</div>
{bandUp ? (
<SelectionBand
ref={bandRef}
count={selection.shownSelected.length}
hidden={selection.hiddenCount}
recentName={recentMoveTarget ? plName(recentMoveTarget) : null}
onMoveRecent={() => recentMoveTarget && moveSelected(recentMoveTarget)}
moveMenuProps={moveMenu.buttonProps("selection")}
moveMenuOpen={moveMenu.open}
onRemove={removeSelected}
onClear={() => {
leaveBand();
selection.clear();
}}
onClearView={viewActive ? clearView : undefined}
trailing={
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
{/* One cell: the row a selection replaces keeps its height, invisible, so the first
tick moves no row under the second tap, however the two wrap. */}
<div className="grid">
{bandUp && (
<div className="[grid-area:1/1]">
<SelectionBand
ref={bandRef}
count={selection.shownSelected.length}
hidden={selection.hiddenCount}
recentName={recentMoveTarget ? plName(recentMoveTarget) : null}
onMoveRecent={() => recentMoveTarget && moveSelected(recentMoveTarget)}
moveMenuProps={moveMenu.buttonProps("selection")}
moveMenuOpen={moveMenu.open}
onRemove={removeSelected}
onClear={() => {
leaveBand();
selection.clear();
}}
onClearView={viewActive ? clearView : undefined}
trailing={
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
}
/>
}
/>
) : (
<div className="flex items-center gap-2 mt-1 flex-wrap">
</div>
)}
<div
aria-hidden={bandUp || undefined}
className={clsx(
"flex items-center gap-2 mt-1 flex-wrap [grid-area:1/1]",
bandUp && "invisible",
)}
>
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
@@ -1323,7 +1335,7 @@ function PlaylistDetail() {
</>
)}
</div>
)}
</div>
</div>
</div>
</div>
@@ -1,11 +1,13 @@
import type { ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderInput, History, X } from "lucide-react";
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. */
* 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. */
export function SelectionBand({
ref,
count,
@@ -36,13 +38,24 @@ export function SelectionBand({
const { t } = useTranslation();
const none = count === 0;
return (
<div ref={ref} className="flex items-center gap-2 mt-1 flex-wrap">
<span role="status" className="text-xs">
<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="font-medium">{t("playlists.selectedCount", { count })}</span>
{hidden > 0 && (
<span className="text-muted">
{" · "}
<span>{t("playlists.selectedHidden", { count: hidden })}</span>
<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>
@@ -52,9 +65,10 @@ export function SelectionBand({
size="xs"
disabled={none}
onClick={onMoveRecent}
label={t("playlists.moveToRecent", { name: recentName })}
icon={<History className="w-3.5 h-3.5" />}
>
<span className="block max-w-48 truncate">
<span className="hidden max-w-48 truncate xl:inline">
{t("playlists.moveToRecent", { name: recentName })}
</span>
</Button>
@@ -64,10 +78,11 @@ export function SelectionBand({
size="xs"
disabled={none}
aria-expanded={moveMenuOpen}
label={t("playlists.moveSelected")}
icon={<FolderInput className="w-3.5 h-3.5" />}
{...moveMenuProps}
>
{t("playlists.moveSelected")}
<span className="hidden xl:inline">{t("playlists.moveSelected")}</span>
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</Button>
<Button
@@ -75,24 +90,31 @@ export function SelectionBand({
size="xs"
disabled={none}
onClick={onRemove}
label={t("playlists.removeSelected")}
icon={<X className="w-3.5 h-3.5" />}
>
{t("playlists.removeSelected")}
<span className="hidden xl:inline">{t("playlists.removeSelected")}</span>
</Button>
<Button
variant="link-muted"
variant="ghost"
size="xs"
onClick={onClear}
className="text-xs underline underline-offset-2"
label={t("playlists.clearSelection")}
icon={<Square className="w-3.5 h-3.5 xl:hidden" />}
className="xl:underline xl:underline-offset-2"
>
{t("playlists.clearSelection")}
<span className="hidden xl:inline">{t("playlists.clearSelection")}</span>
</Button>
{onClearView && (
<Button
variant="link-muted"
variant="ghost"
size="xs"
onClick={onClearView}
className="text-xs underline underline-offset-2"
label={t("playlists.clearView")}
icon={<ListRestart className="w-3.5 h-3.5 xl:hidden" />}
className="xl:underline xl:underline-offset-2"
>
{t("playlists.clearView")}
<span className="hidden xl:inline">{t("playlists.clearView")}</span>
</Button>
)}
<div className="ml-auto">{trailing}</div>