fix(filters): a pick that hides More… keeps the focus, counts read alike, Plex facets say they failed

Picking in the year dialog the one year the chips hid took More… away with the dialog, and the
focus fell to <body>: it now goes to the picked year's chip. A count badge is compact in the UI
language everywhere (a chip read "17200" beside its cell's "17.2K"). While the Plex facets load
or fail, the sections they fill say so instead of vanishing as if the library had none.
This commit is contained in:
2026-11-14 19:32:01 +01:00
parent 36f9aefb76
commit be6bd586d7
6 changed files with 198 additions and 83 deletions
+7 -2
View File
@@ -1,15 +1,20 @@
import { useTranslation } from "react-i18next";
import { formatCompact } from "../../lib/format";
// A small count pill shown beside a tab label or filter chip (pending requests, item counts). One
// source for the count-badge token so Tabs and FilterChipGroup can't drift. The `active` variant sits
// on an accent-filled parent (a pressed tab/chip), so it tints against accent-fg; the resting variant
// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill.
// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill. The count is
// compact in the UI language ("17.2K" / "17,2 E"), the same as every other count in the app.
export default function CountBadge({ count, active }: { count: number; active?: boolean }) {
const { i18n } = useTranslation();
return (
<span
className={`tabular-nums text-2xs leading-none px-1.5 py-0.5 rounded-full ${
active ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{count}
{formatCompact(count, i18n.language)}
</span>
);
}
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { useState } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, within, act } from "@testing-library/react";
@@ -88,6 +89,44 @@ describe("YearPicker", () => {
expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy();
});
it("offers no More… once every year with titles is a chip", () => {
const { rerender } = render(<YearPicker years={YEARS.slice(0, 6)} onPick={() => {}} />);
expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull();
// Seven years, the seventh selected: it is kept as a chip, so nothing is left for the dialog.
rerender(<YearPicker years={YEARS.slice(0, 7)} selected={2013} onPick={() => {}} />);
expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull();
});
it("picking the one year the chips hid moves the focus to its chip, not to <body>", () => {
// Seven years: the pick keeps 2013 as a chip, so More… leaves with the dialog it opened.
function Picker() {
const [year, setYear] = useState<number | undefined>();
return <YearPicker years={YEARS.slice(0, 7)} selected={year} onPick={setYear} />;
}
vi.useFakeTimers();
try {
render(<Picker />);
const more = screen.getByRole("button", { name: "sidebar.year.more" });
more.focus();
fireEvent.click(more);
const cell = screen.getByRole("dialog").querySelector<HTMLElement>('[data-year="2013"]')!;
cell.focus();
fireEvent.click(cell);
act(() => {
vi.runAllTimers();
});
expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull();
expect(document.activeElement).toBe(screen.getByRole("button", { pressed: true }));
} finally {
vi.useRealTimers();
}
});
it("a chip's count is compact too, the same as its cell's", () => {
render(<YearPicker years={[{ year: 2025, count: 17243 }]} onPick={() => {}} />);
expect(screen.getByRole("button").textContent).toBe("202517.2K");
});
it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
expect(
+15 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Chip } from "./Chip";
import CountBadge from "./CountBadge";
@@ -52,10 +52,22 @@ export default function YearPicker({
// only if it is one of them.
const more = years.some((y) => !quick.some((q) => q.year === y.year));
const chipsRef = useRef<HTMLDivElement>(null);
const toggle = (year: number) => onPick(selected === year ? undefined : year);
const pickAndClose = (year: number) => {
toggle(year);
setOpen(false);
// A pick that leaves the dialog nothing to show takes More… away with it, and the dialog then has
// nowhere to give the focus back: it fell to <body>, which also folded an unpinned rail. So it
// goes to the picked year's chip (the first chip if the pick cleared it). A timer, not a frame: a
// page the browser is not painting runs no requestAnimationFrame.
window.setTimeout(() => {
const now = document.activeElement;
const row = chipsRef.current;
if ((now && now !== document.body && now.isConnected) || !row) return;
const chip = row.querySelector<HTMLElement>(`[data-year="${year}"]`);
(chip ?? row.querySelector<HTMLElement>("button"))?.focus({ preventScroll: true });
}, 0);
};
const cell = (y: YearCount, index: number) => {
@@ -79,7 +91,7 @@ export default function YearPicker({
};
return (
<div className="flex flex-wrap gap-1.5">
<div ref={chipsRef} className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
// The chip kept only for the selection leaves with it: from the keyboard, the focus goes to
@@ -89,6 +101,7 @@ export default function YearPicker({
<Chip
key={y.year}
pressed={on}
data-year={y.year}
onClick={(e) => {
if (leaves && e.currentTarget === document.activeElement)
handOffFocus(e.currentTarget, () => toggle(y.year));