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