fix: tag counts, chip focus, upload naming, share links and unsized clips hold after round 2

- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a
  subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its
  hover list count the same channels as the chip; /api/tags sends both counts under their names.
- A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in
  the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place.
  The fold is a dialog of toggle buttons, not an empty menu.
- Upload: a folder name left by a press is made once the press is over, if the dialog is still
  open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place
  holds the focus and a live region says so.
- Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached
  list, so a reopen makes no second one; the New link form survives the link made on open.
- Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with
  their rail.
- An unsized clip of a rotated file gets the upright size its crop cut.
This commit is contained in:
2026-11-06 02:20:52 +01:00
parent 2baad2ebae
commit 4d1cf66d96
30 changed files with 526 additions and 128 deletions
@@ -9,7 +9,13 @@ import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: { action?: string; title?: string }) =>
o?.title ? `${o.action}: ${o.title}` : k,
}),
};
});
import { Carousel } from "./Carousel";
@@ -54,7 +60,10 @@ describe("Carousel", () => {
);
expect(screen.getByText("card-a")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
// Named with its rail, the visible words first: a page of rails has a See all on each.
const seeAll = screen.getByRole("button", { name: "common.seeAll: Recently added" });
expect(seeAll.textContent).toBe("common.seeAll");
fireEvent.click(seeAll);
expect(onSeeAll).toHaveBeenCalledOnce();
});
+15 -1
View File
@@ -109,7 +109,21 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
<Button
variant="link-muted"
onClick={onSeeAll}
// Named with its rail: a page of rails has a See all on each, and a screen reader's
// list of buttons (or a voice command) must tell them apart. The visible words lead.
aria-label={
label
? t("common.seeAllOf", {
action: seeAllLabel ?? t("common.seeAll"),
title: label,
})
: undefined
}
className="ml-auto gap-0.5 text-xs"
>
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</Button>
-1
View File
@@ -231,7 +231,6 @@ export default function Modal({
size="sm"
variant="ghost"
onClick={onClose}
data-dismiss=""
>
<X className="w-4 h-4" />
</IconButton>