fix(playlists): the list's name keeps its room on a narrow window

Below xl Unsynced is its dot; on a phone the cover is gone, Play all is its
glyph, the band's count is its number and the undo pair stays out. The name was
0 px wide in Hungarian at 768 and on any phone, and a tick moved the list there.
This commit is contained in:
2026-11-01 23:07:42 +01:00
parent bf54a3ce5f
commit 9b76d2f96c
4 changed files with 66 additions and 7 deletions
+2
View File
@@ -546,6 +546,7 @@ of a full-width panel and nothing on the right.
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.
On a phone the count is its number (the words read out, and in the tooltip) and the undo pair stays out.
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
@@ -848,3 +849,4 @@ of a full-width panel and nothing on the right.
| 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. |
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. |
@@ -1134,6 +1134,43 @@ describe("Playlists — selecting rows", () => {
});
});
describe("Playlists — the list's name keeps its room on a narrow window", () => {
const head = (lists: Record<number, Video[]>) =>
mount(
lists,
{},
{
detail: async (id: number) => ({
...list(id, lists[id] ?? []),
yt_playlist_id: "PLx",
dirty: true,
}),
},
);
it("says Unsynced by its dot below xl: the word still read out, the hint in the tooltip", async () => {
head({ 1: [vid("A", 0)] });
const word = await screen.findByText("playlists.unsynced");
expect(word.classList.contains("sr-only")).toBe(true);
expect(word.classList.contains("xl:not-sr-only")).toBe(true);
expect(word.parentElement!.getAttribute("title")).toBe("playlists.unsyncedHint");
});
it("drops Play all's word and the cover on a phone, and keeps the button's name", async () => {
head({ 1: [vid("A", 0)] });
const play = await screen.findByRole("button", { name: "playlists.playAll" });
expect(play.getAttribute("title")).toBe("playlists.playAll");
const word = [...play.querySelectorAll("span")].find(
(s) => s.textContent === "playlists.playAll",
)!;
expect(word.classList.contains("hidden")).toBe(true);
expect(word.classList.contains("sm:inline")).toBe(true);
const cover = document.querySelector('[class*="h-11.25"]')!;
expect(cover.classList.contains("hidden")).toBe(true);
expect(cover.classList.contains("sm:block")).toBe(true);
});
});
describe("Playlists — acting on the selected rows", () => {
// A move remembers its target on the server, and an answer from nowhere would be the last toast.
beforeEach(() => void vi.spyOn(api, "savePrefs").mockResolvedValue({} as never));
@@ -1500,7 +1537,14 @@ describe("Playlists — acting on the selected rows", () => {
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);
const words = screen.getByText("playlists.selectedCount");
expect(words.classList.contains("sr-only")).toBe(true);
expect(words.classList.contains("sm:not-sr-only")).toBe(true);
const number = screen.getByTitle("playlists.selectedCount");
expect(number.getAttribute("aria-hidden")).toBe("true");
expect(number.classList.contains("sm:hidden")).toBe(true);
const undo = screen.getByRole("button", { name: "common.undo" });
expect(undo.closest("[class~='sm:block']")).toBeTruthy();
});
});
+6 -3
View File
@@ -1174,7 +1174,9 @@ function PlaylistDetail() {
cover plus a fourth block under it, for roughly twice this height. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{/* Off on a phone, and the state's word and Play all's shrink: past them the list's name had
no width left at all. */}
<div className="hidden w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0 sm:block">
{items[0]?.thumbnail_url && (
<RemoteImage
src={items[0].thumbnail_url}
@@ -1217,16 +1219,17 @@ function PlaylistDetail() {
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
<span className="sr-only xl:not-sr-only">{t("playlists.unsynced")}</span>
</span>
)}
<Button
size="sm"
onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length}
label={t("playlists.playAll")}
icon={<Play className="fill-current" />}
>
{t("playlists.playAll")}
<span className="hidden sm:inline">{t("playlists.playAll")}</span>
</Button>
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
menu, the same the rail row opens — the header keeps Play all. */}
@@ -7,7 +7,8 @@ import { Button } from "../../components/ui/Button";
* `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. */
* 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. */
export function SelectionBand({
ref,
count,
@@ -40,7 +41,16 @@ export function SelectionBand({
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="font-medium">{t("playlists.selectedCount", { count })}</span>
<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">
@@ -117,7 +127,7 @@ export function SelectionBand({
<span className="hidden xl:inline">{t("playlists.clearView")}</span>
</Button>
)}
<div className="ml-auto">{trailing}</div>
<div className="ml-auto hidden sm:block">{trailing}</div>
</div>
);
}