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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user