refactor(ui): shared FilterChipGroup; unify feed filter chips (R13 S2c step 2)

New single-select FilterChipGroup primitive on the canonical rounded-full
"Filters bar" chip look (pill + shadow-xs + press feedback), plus an exported
filterChipClass so multi-select siblings share ONE geometry source of truth.

Migrations:
- feed watch-state chips (F1, Feed.tsx) -> FilterChipGroup, testIds preserved
- feed content-type toggles restyled to filterChipClass (stay multi-select) so
  the whole toolbar row is one chip look; wrapped in their own labelled group
- feed date presets + Custom (F3, Sidebar.tsx) -> FilterChipGroup with a
  behaviour-preserving dispatch (preset toggle-off + Custom editor intact)

Adds aria-label i18n (feed.showLabel/contentLabel, sidebar.datePresetLabel;
HU+EN) and FilterChipGroup.dom.test.tsx (6 tests). Self-E2E'd: watch switch,
date preset on/off toggle, Custom opens the range editor. gate green (vitest
136, tsc/eslint-0/knip/prettier). Branch not merged.
This commit is contained in:
2026-08-15 00:35:22 +02:00
parent 61ee7af9e4
commit 579af9cf89
8 changed files with 201 additions and 56 deletions
+29 -25
View File
@@ -40,6 +40,7 @@ import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -563,32 +564,35 @@ export default function Feed({
const toolbar = (
<div className="pb-3">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{SHOW_IDS.map((id) => (
<button
key={id}
data-testid={`feed-show-${id}`}
onClick={() => setFilters({ ...filters, show: id })}
aria-pressed={filters.show === id}
className="pill text-xs px-3 py-1.5 rounded-full border"
>
{t("sidebar.show." + id)}
</button>
))}
<FilterChipGroup
ariaLabel={t("feed.showLabel")}
options={SHOW_IDS.map((id) => ({
value: id,
label: t("sidebar.show." + id),
testId: `feed-show-${id}`,
}))}
value={filters.show}
onChange={(show) => setFilters({ ...filters, show })}
/>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
{CONTENT.map((c) => {
const on = filters[c.key];
return (
<button
key={c.key}
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className="pill text-xs px-3 py-1.5 rounded-full border"
>
{t(c.label)}
</button>
);
})}
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
filterChipClass, but not a single-select group. */}
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
{CONTENT.map((c) => {
const on = filters[c.key];
return (
<button
key={c.key}
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className={filterChipClass}
>
{t(c.label)}
</button>
);
})}
</div>
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
it selects how videos ENTERED the library. On a channel-scoped view we already show all
of this one channel's videos (librarySource is pinned to "all"), so the selector would
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
//
// Rendering integration of the shared single-select FilterChipGroup. Labels are plain props; active
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
const OPTS = [
{ value: "all", label: "All", testId: "chip-all" },
{ value: "needsFull", label: "Needs full" },
{ value: "fully", label: "Fully synced", count: 3 },
] as const;
afterEach(cleanup);
describe("FilterChipGroup", () => {
it("renders one chip per option with labels", () => {
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
"All",
"Needs full",
"Fully synced3",
]);
});
it("marks exactly the active chip aria-pressed", () => {
render(
<FilterChipGroup options={OPTS} value="needsFull" onChange={vi.fn()} ariaLabel="Sync" />,
);
expect(screen.getByRole("button", { name: "Needs full" }).getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.getByRole("button", { name: "All" }).getAttribute("aria-pressed")).toBe("false");
});
it("clicking a chip fires onChange with its value", () => {
const onChange = vi.fn();
render(<FilterChipGroup options={OPTS} value="all" onChange={onChange} ariaLabel="Sync" />);
fireEvent.click(screen.getByRole("button", { name: "Needs full" }));
expect(onChange).toHaveBeenCalledWith("needsFull");
});
it("re-clicking the active chip still fires onChange (caller decides toggle-off)", () => {
const onChange = vi.fn();
render(<FilterChipGroup options={OPTS} value="all" onChange={onChange} ariaLabel="Sync" />);
fireEvent.click(screen.getByRole("button", { name: "All" }));
expect(onChange).toHaveBeenCalledWith("all");
});
it("preserves per-option testId and exposes the group aria-label", () => {
render(
<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync state" />,
);
expect(screen.getByTestId("chip-all")).toBeTruthy();
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
});
it("exports the canonical chip class for multi-select siblings to reuse", () => {
expect(filterChipClass).toContain("rounded-full");
expect(filterChipClass).toContain("pill");
});
});
@@ -0,0 +1,65 @@
import { type ReactNode } from "react";
// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
export const filterChipClass =
"pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
export interface FilterChipOption<T extends string> {
value: T;
label: string;
count?: number; // optional trailing count badge
icon?: ReactNode;
title?: string; // optional per-chip tooltip
testId?: string; // optional data-testid (preserve existing e2e hooks)
}
// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
export default function FilterChipGroup<T extends string>({
options,
value,
onChange,
ariaLabel,
className = "flex flex-wrap gap-1.5",
}: {
options: readonly FilterChipOption<T>[];
value: T;
onChange: (v: T) => void;
ariaLabel: string;
// Override the row wrapper (e.g. "contents" to dissolve it into a shared toolbar flex row).
className?: string;
}) {
return (
<div className={className} role="group" aria-label={ariaLabel}>
{options.map((o) => {
const on = value === o.value;
return (
<button
key={o.value}
data-testid={o.testId}
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={on}
className={filterChipClass}
>
{o.icon}
<span>{o.label}</span>
{o.count != null && (
<span
className={`tabular-nums text-3xs leading-none px-1 py-0.5 rounded-full ${
on ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
}`}
>
{o.count}
</span>
)}
</button>
);
})}
</div>
);
}
+38 -31
View File
@@ -16,6 +16,7 @@ import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
@@ -150,42 +151,47 @@ export default function Sidebar({
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date":
case "date": {
// Single-select across the presets + Custom: a preset is active only when its maxAge matches
// and no custom range is open; otherwise Custom owns the selection (or none).
const activePresetDays = DATE_PRESETS.find(
(p) => filters.maxAgeDays === p.days && !customDates,
)?.days;
const dateKey =
customDates || filters.dateFrom || filters.dateTo
? "custom"
: activePresetDays != null
? String(activePresetDays)
: "";
return (
<>
<div className="flex flex-wrap gap-1.5">
{DATE_PRESETS.map((p) => {
const activePreset = filters.maxAgeDays === p.days && !customDates;
return (
<button
key={p.days}
onClick={() =>
setFilters({
...filters,
maxAgeDays: activePreset ? undefined : p.days,
dateFrom: undefined,
dateTo: undefined,
})
}
aria-pressed={activePreset}
className="pill text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px"
>
{t("sidebar.datePreset." + p.key)}
</button>
);
})}
<button
onClick={() => {
<FilterChipGroup
ariaLabel={t("sidebar.datePresetLabel")}
value={dateKey}
options={[
...DATE_PRESETS.map((p) => ({
value: String(p.days),
label: t("sidebar.datePreset." + p.key),
})),
{ value: "custom", label: t("sidebar.custom") },
]}
onChange={(key) => {
if (key === "custom") {
const next = !customDates;
setCustomDates(next);
if (next) setFilters({ ...filters, maxAgeDays: undefined });
}}
aria-pressed={!!(customDates || filters.dateFrom || filters.dateTo)}
className="pill text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px"
>
{t("sidebar.custom")}
</button>
</div>
return;
}
const days = Number(key);
const activePreset = filters.maxAgeDays === days && !customDates;
setFilters({
...filters,
maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
});
}}
/>
{(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2">
@@ -233,6 +239,7 @@ export default function Sidebar({
)}
</>
);
}
case "language": {
const chips = visibleChips(languages);
return (
+2
View File
@@ -20,6 +20,8 @@
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"viewLabel": "View",
"showLabel": "Watch state",
"contentLabel": "Content types",
"view": {
"cards": "Large cards",
"cardsSmall": "Small cards",
@@ -66,6 +66,7 @@
"shorts": "Shorts",
"live": "Live / Upcoming"
},
"datePresetLabel": "Upload date",
"datePreset": {
"24h": "24h",
"1week": "1 week",
+2
View File
@@ -20,6 +20,8 @@
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"viewLabel": "Nézet",
"showLabel": "Megtekintési állapot",
"contentLabel": "Tartalomtípusok",
"view": {
"cards": "Nagy kártya",
"cardsSmall": "Kis kártya",
@@ -66,6 +66,7 @@
"shorts": "Shorts",
"live": "Élő / Hamarosan"
},
"datePresetLabel": "Feltöltés dátuma",
"datePreset": {
"24h": "24 óra",
"1week": "1 hét",