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
+10 -26
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { clickVisibleChannelLink, openApp } from "./helpers"; import { clickVisibleChannelLink, expectContentType, openApp, toggleContentType } from "./helpers";
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and // Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch // both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
@@ -68,29 +68,22 @@ test.describe("channel view", () => {
}) => { }) => {
await openApp(page); await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
const feedLive = page.getByTestId("feed-content-includeLive"); await expectContentType(page, "includeLive", false);
await expect(feedLive).toHaveAttribute("aria-pressed", "false");
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on. // Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
await page.getByTestId("video-card-channel").first().click(); await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible(); await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( await expectContentType(page, "includeLive", false);
"aria-pressed",
"false",
);
await page.getByTestId("channel-back").click(); await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0); await expect(page.getByTestId("channel-title")).toHaveCount(0);
// Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state. // Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state.
await feedLive.click(); await toggleContentType(page, "includeLive");
await expect(feedLive).toHaveAttribute("aria-pressed", "true"); await expectContentType(page, "includeLive", true);
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click(); await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible(); await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( await expectContentType(page, "includeLive", true);
"aria-pressed",
"true",
);
}); });
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there // Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
@@ -99,27 +92,18 @@ test.describe("channel view", () => {
test("a channel's content toggle does not leak into the feed", async ({ page }) => { test("a channel's content toggle does not leak into the feed", async ({ page }) => {
await openApp(page); await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( await expectContentType(page, "includeLive", false);
"aria-pressed",
"false",
);
// Turn Live/Upcoming ON inside the channel view. // Turn Live/Upcoming ON inside the channel view.
await page.getByTestId("video-card-channel").first().click(); await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible(); await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("feed-content-includeLive").click(); await toggleContentType(page, "includeLive");
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( await expectContentType(page, "includeLive", true);
"aria-pressed",
"true",
);
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local. // Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
await page.getByTestId("channel-back").click(); await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0); await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( await expectContentType(page, "includeLive", false);
"aria-pressed",
"false",
);
}); });
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays // Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
+8 -4
View File
@@ -12,11 +12,14 @@ test.describe("feed", () => {
await expect(count).toHaveText(/\d/); await expect(count).toHaveText(/\d/);
const baseline = await count.innerText(); const baseline = await count.innerText();
// Clean baseline: default filters, so no chips and "unwatched" is the active show. // Clean baseline: default filters, so no chips and "unwatched" is the active show (the Watch-state
// pill summarises the current pick).
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0); await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
// Narrow to "watched" — the seed guarantees exactly one, a real deterministic change. // Narrow to "watched" — open the Watch-state dropdown and pick it (single-select closes on pick).
// The seed guarantees exactly one, a real deterministic change.
await page.getByTestId("feed-show").click();
await page.getByTestId("feed-show-watched").click(); await page.getByTestId("feed-show-watched").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(1); await expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
await expect(count).not.toHaveText(baseline); await expect(count).not.toHaveText(baseline);
@@ -24,7 +27,7 @@ test.describe("feed", () => {
// Remove the chip → default show restored, chips gone. // Remove the chip → default show restored, chips gone.
await page.getByTestId("active-filter-chip").click(); await page.getByTestId("active-filter-chip").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0); await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
}); });
test("scrolling virtualizes in fresh cards", async ({ page }) => { test("scrolling virtualizes in fresh cards", async ({ page }) => {
@@ -73,6 +76,7 @@ test.describe("feed", () => {
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500); expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
// Switch the show filter → a different result set → back to the top. // Switch the show filter → a different result set → back to the top.
await page.getByTestId("feed-show").click();
await page.getByTestId("feed-show-all").click(); await page.getByTestId("feed-show-all").click();
await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50); await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50);
}); });
+23
View File
@@ -33,6 +33,29 @@ export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
return false; return false;
} }
/**
* Assert a content-type (e.g. "includeLive") is on/off via the Content-types FilterSelect dropdown.
* The options only exist while the dropdown is open, so this opens it, checks the option's
* `aria-selected`, then closes it (Escape) so the floating panel can't block a later click.
*/
export async function expectContentType(page: Page, value: string, on: boolean): Promise<void> {
await page.getByTestId("feed-content").click();
await expect(page.getByTestId(`feed-content-${value}`)).toHaveAttribute(
"aria-selected",
on ? "true" : "false",
);
await page.keyboard.press("Escape");
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
}
/** Toggle a content-type via the Content-types FilterSelect dropdown, then close it. */
export async function toggleContentType(page: Page, value: string): Promise<void> {
await page.getByTestId("feed-content").click();
await page.getByTestId(`feed-content-${value}`).click();
await page.keyboard.press("Escape");
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
}
/** Open the in-app player from the first feed card (clicks its thumbnail link). */ /** Open the in-app player from the first feed card (clicks its thumbnail link). */
export async function openFirstVideo(page: Page): Promise<void> { export async function openFirstVideo(page: Page): Promise<void> {
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
+21 -29
View File
@@ -40,7 +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 FilterSelect from "./FilterSelect";
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.
@@ -570,35 +570,27 @@ 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">
<FilterChipGroup {/* Watch state is single-select (always exactly one); content types are an independent
ariaLabel={t("feed.showLabel")} multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
options={SHOW_IDS.map((id) => ({ <FilterSelect
value: id, label={t("feed.showLabel")}
label: t("sidebar.show." + id), testId="feed-show"
testId: `feed-show-${id}`, options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
}))} selected={[filters.show]}
value={filters.show} onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
onChange={(show) => setFilters({ ...filters, 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 — {/* 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,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>
</>
);
}