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 { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider"; import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl"; import SortControl from "./SortControl";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import ViewSwitcher from "./ViewSwitcher"; import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. // 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 = ( const toolbar = (
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap"> <div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{SHOW_IDS.map((id) => ( <FilterChipGroup
<button ariaLabel={t("feed.showLabel")}
key={id} options={SHOW_IDS.map((id) => ({
data-testid={`feed-show-${id}`} value: id,
onClick={() => setFilters({ ...filters, show: id })} label: t("sidebar.show." + id),
aria-pressed={filters.show === id} testId: `feed-show-${id}`,
className="pill text-xs px-3 py-1.5 rounded-full border" }))}
> value={filters.show}
{t("sidebar.show." + id)} onChange={(show) => setFilters({ ...filters, show })}
</button> />
))}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
{CONTENT.map((c) => { {/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
const on = filters[c.key]; filterChipClass, but not a single-select group. */}
return ( <div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
<button {CONTENT.map((c) => {
key={c.key} const on = filters[c.key];
data-testid={`feed-content-${c.key}`} return (
onClick={() => setFilters({ ...filters, [c.key]: !on })} <button
aria-pressed={on} key={c.key}
className="pill text-xs px-3 py-1.5 rounded-full border" data-testid={`feed-content-${c.key}`}
> onClick={() => setFilters({ ...filters, [c.key]: !on })}
{t(c.label)} aria-pressed={on}
</button> className={filterChipClass}
); >
})} {t(c.label)}
</button>
);
})}
</div>
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept — {/* 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 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 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 PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker"; import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle"; import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider"; import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
@@ -150,42 +151,47 @@ export default function Sidebar({
return <SavedViewsWidget filters={filters} onApply={setFilters} />; return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel": case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />; 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 ( return (
<> <>
<div className="flex flex-wrap gap-1.5"> <FilterChipGroup
{DATE_PRESETS.map((p) => { ariaLabel={t("sidebar.datePresetLabel")}
const activePreset = filters.maxAgeDays === p.days && !customDates; value={dateKey}
return ( options={[
<button ...DATE_PRESETS.map((p) => ({
key={p.days} value: String(p.days),
onClick={() => label: t("sidebar.datePreset." + p.key),
setFilters({ })),
...filters, { value: "custom", label: t("sidebar.custom") },
maxAgeDays: activePreset ? undefined : p.days, ]}
dateFrom: undefined, onChange={(key) => {
dateTo: undefined, if (key === "custom") {
})
}
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={() => {
const next = !customDates; const next = !customDates;
setCustomDates(next); setCustomDates(next);
if (next) setFilters({ ...filters, maxAgeDays: undefined }); if (next) setFilters({ ...filters, maxAgeDays: undefined });
}} return;
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" const days = Number(key);
> const activePreset = filters.maxAgeDays === days && !customDates;
{t("sidebar.custom")} setFilters({
</button> ...filters,
</div> maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
});
}}
/>
{(customDates || filters.dateFrom || filters.dateTo) && ( {(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2"> <div className="flex flex-col gap-1.5 mt-2">
@@ -233,6 +239,7 @@ export default function Sidebar({
)} )}
</> </>
); );
}
case "language": { case "language": {
const chips = visibleChips(languages); const chips = visibleChips(languages);
return ( return (
+2
View File
@@ -20,6 +20,8 @@
"videoCount_other": "{{formattedCount}} videos", "videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort", "sortLabel": "Sort",
"viewLabel": "View", "viewLabel": "View",
"showLabel": "Watch state",
"contentLabel": "Content types",
"view": { "view": {
"cards": "Large cards", "cards": "Large cards",
"cardsSmall": "Small cards", "cardsSmall": "Small cards",
@@ -66,6 +66,7 @@
"shorts": "Shorts", "shorts": "Shorts",
"live": "Live / Upcoming" "live": "Live / Upcoming"
}, },
"datePresetLabel": "Upload date",
"datePreset": { "datePreset": {
"24h": "24h", "24h": "24h",
"1week": "1 week", "1week": "1 week",
+2
View File
@@ -20,6 +20,8 @@
"videoCount_other": "{{formattedCount}} videó", "videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés", "sortLabel": "Rendezés",
"viewLabel": "Nézet", "viewLabel": "Nézet",
"showLabel": "Megtekintési állapot",
"contentLabel": "Tartalomtípusok",
"view": { "view": {
"cards": "Nagy kártya", "cards": "Nagy kártya",
"cardsSmall": "Kis kártya", "cardsSmall": "Kis kártya",
@@ -66,6 +66,7 @@
"shorts": "Shorts", "shorts": "Shorts",
"live": "Élő / Hamarosan" "live": "Élő / Hamarosan"
}, },
"datePresetLabel": "Feltöltés dátuma",
"datePreset": { "datePreset": {
"24h": "24 óra", "24h": "24 óra",
"1week": "1 hét", "1week": "1 hét",