feat(feed): compress the toolbar chip groups into labeled FilterSelect dropdowns

Adds a reusable FilterSelect: a "Label: value ▾" pill that opens a listbox (a checkmark
row for single-select, a checkbox row for multi), on the AnchoredPopover platform. It
replaces the two feed-toolbar chip walls:
  - Watch state (single-select, default All) — picking a row replaces + closes.
  - Content types (multi-select, default Normal; requireOne keeps at least one on) — rows
    toggle and the panel stays open.
The trigger summarises multi picks as "First +N". The component is source-agnostic, meant
for reuse anywhere a small known option set currently renders as a chip group.

FilterSelect takes a `testId` → the trigger gets it, each option gets `${testId}-${value}`,
so the feed/channel E2Es keep stable selectors. They now open the dropdown and assert the
option's aria-selected instead of the old inline aria-pressed chips (new expectContentType/
toggleContentType helpers).
This commit is contained in:
2026-08-21 17:28:57 +02:00
parent ad0cf5827b
commit 5851ddd329
6 changed files with 289 additions and 59 deletions
+21 -29
View File
@@ -40,7 +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 FilterSelect from "./FilterSelect";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -570,35 +570,27 @@ export default function Feed({
const toolbar = (
<div className="pb-3">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
<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 })}
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
selected={[filters.show]}
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
onChange={(next) => {
const patch = { ...filters };
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
setFilters(patch);
}}
/>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
{/* 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,92 @@
// @vitest-environment jsdom
//
// Contract of the labeled-pill FilterSelect. Labels are plain props (no i18n). Built on the floating-ui
// AnchoredPopover platform, so we stub the two observers autoUpdate wires and wrap in a FloatingTree.
// floating-ui never positions in jsdom → the surface stays visibility:hidden, so option/listbox role
// queries pass `{ hidden: true }`.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import FilterSelect, { type FilterSelectOption } from "./FilterSelect";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
const STATUS: FilterSelectOption[] = [
{ value: "all", label: "All" },
{ value: "new", label: "New" },
];
const TYPES: FilterSelectOption[] = [
{ value: "n", label: "Normal" },
{ value: "s", label: "Shorts" },
];
const tree = (ui: React.ReactNode) => render(<FloatingTree>{ui}</FloatingTree>);
describe("FilterSelect", () => {
it("renders a labeled trigger `Label: value` and keeps options unmounted until opened", () => {
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={vi.fn()} />);
const trigger = screen.getByRole("button", { name: "Status" });
expect(trigger.textContent).toContain("Status:");
expect(trigger.textContent).toContain("All");
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
});
it("single: picking a row replaces the selection and closes", () => {
const onChange = vi.fn();
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Status" }));
expect(screen.getAllByRole("option", { hidden: true }).map((o) => o.textContent)).toEqual([
"All",
"New",
]);
fireEvent.click(screen.getAllByRole("option", { hidden: true })[1]!); // New
expect(onChange).toHaveBeenCalledWith(["new"]);
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
});
it("multiple: toggling a row adds it and keeps the panel open", () => {
const onChange = vi.fn();
tree(
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Type" }));
fireEvent.click(screen.getAllByRole("option", { hidden: true })[1]!); // Shorts
expect(onChange).toHaveBeenCalledWith(["n", "s"]);
expect(screen.queryAllByRole("option", { hidden: true }).length).toBeGreaterThan(0); // still open
});
it("multiple + requireOne: the last remaining selection can't be turned off", () => {
const onChange = vi.fn();
tree(
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Type" }));
fireEvent.click(screen.getAllByRole("option", { hidden: true })[0]!); // Normal — the only one selected
expect(onChange).not.toHaveBeenCalled();
});
it("multiple: the trigger summarises extra picks as `First +N`", () => {
tree(
<FilterSelect
label="Type"
multiple
options={TYPES}
selected={["n", "s"]}
onChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Type" }).textContent).toContain("Normal +1");
});
});
+135
View File
@@ -0,0 +1,135 @@
import { useState } from "react";
import { ChevronDown, Check } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
// it opens a listbox of options (facet counts optional). It replaces a wall of individual toolbar chips
// with one pill per filter group, in two flavours:
// - single (default): picking a row replaces the choice and closes — always exactly one active.
// - multiple: rows toggle independently and the panel stays open; `requireOne` blocks clearing the last.
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
export interface FilterSelectOption {
value: string;
label: string;
/** A facet count shown at the row's end. */
count?: number;
disabled?: boolean;
}
export interface FilterSelectProps {
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
label: string;
options: readonly FilterSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
/** Toggle a subset (default false = pick exactly one). */
multiple?: boolean;
/** Multi only: keep at least one selected — the last active row can't be turned off. Default true. */
requireOne?: boolean;
/** Test hook: the trigger gets `data-testid={testId}`; each option gets `${testId}-${value}`. */
testId?: string;
}
export default function FilterSelect({
label,
options,
selected,
onChange,
multiple = false,
requireOne = true,
testId,
}: FilterSelectProps) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "listbox",
placement: "bottom-start",
});
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
// Summarise the trigger value. Multi lists the first chosen (in options order, so it's stable) + "+N".
const chosen = options.filter((o) => selected.includes(o.value));
const first = chosen[0];
const summary = multiple
? first
? `${first.label}${chosen.length > 1 ? ` +${chosen.length - 1}` : ""}`
: "—"
: labelOf(selected[0] ?? options[0]?.value ?? "");
const commit = (value: string) => {
if (!multiple) {
onChange([value]);
setOpen(false);
return;
}
const has = selected.includes(value);
if (has) {
if (requireOne && selected.length === 1) return; // don't let the last one go
onChange(selected.filter((x) => x !== value));
} else {
onChange([...selected, value]);
}
};
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
data-testid={testId}
aria-label={label}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition ${
open ? "border-accent" : "border-border"
}`}
>
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{options.map((o) => {
const on = selected.includes(o.value);
return (
<PopoverItem
key={o.value}
role="option"
selected={on}
disabled={o.disabled}
onSelect={() => commit(o.value)}
label={o.label}
data-testid={testId ? `${testId}-${o.value}` : undefined}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
{multiple ? (
<span
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
on ? "border-accent bg-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="h-2.5 w-2.5" />}
</span>
) : (
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
)}
<span className="flex-1 truncate">{o.label}</span>
{o.count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
)}
</PopoverItem>
);
})}
</PopoverSurface>
</>
);
}