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. 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 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. 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. - **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3] [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 | 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 | 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 | 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", () => { 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. // 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)); 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("aria-hidden")).toBe("true");
expect(short.getAttribute("title")).toBe("playlists.selectedHidden"); expect(short.getAttribute("title")).toBe("playlists.selectedHidden");
expect(short.classList.contains("xl:hidden")).toBe(true); 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. */} 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="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center"> <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 && ( {items[0]?.thumbnail_url && (
<RemoteImage <RemoteImage
src={items[0].thumbnail_url} 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" 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" /> <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> </span>
)} )}
<Button <Button
size="sm" size="sm"
onClick={() => shown.length && openPlayer(shown[0]!.id)} onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length} disabled={!shown.length}
label={t("playlists.playAll")}
icon={<Play className="fill-current" />} icon={<Play className="fill-current" />}
> >
{t("playlists.playAll")} <span className="hidden sm:inline">{t("playlists.playAll")}</span>
</Button> </Button>
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE {/* 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. */} 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 * `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 * 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({ export function SelectionBand({
ref, ref,
count, count,
@@ -40,7 +41,16 @@ export function SelectionBand({
return ( return (
<div ref={ref} className="flex items-center gap-1 mt-1 flex-wrap xl:gap-2"> <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 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 && ( {hidden > 0 && (
<span className="text-muted"> <span className="text-muted">
<span aria-hidden="true" className="hidden xl:inline"> <span aria-hidden="true" className="hidden xl:inline">
@@ -117,7 +127,7 @@ export function SelectionBand({
<span className="hidden xl:inline">{t("playlists.clearView")}</span> <span className="hidden xl:inline">{t("playlists.clearView")}</span>
</Button> </Button>
)} )}
<div className="ml-auto">{trailing}</div> <div className="ml-auto hidden sm:block">{trailing}</div>
</div> </div>
); );
} }