diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 64b062ef..693cd4e5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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. | diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 362ebed9..4ed5ee13 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 1e7f45ff..93f6c261 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -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", diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index 92a27267..79d13f60 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -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"); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index 54bb6387..8e2fff67 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -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() { - {bandUp ? ( -