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:
@@ -1,5 +1,5 @@
|
||||
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
|
||||
// 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 expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
const feedLive = page.getByTestId("feed-content-includeLive");
|
||||
await expect(feedLive).toHaveAttribute("aria-pressed", "false");
|
||||
await expectContentType(page, "includeLive", false);
|
||||
|
||||
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
await page.getByTestId("channel-back").click();
|
||||
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.
|
||||
await feedLive.click();
|
||||
await expect(feedLive).toHaveAttribute("aria-pressed", "true");
|
||||
await toggleContentType(page, "includeLive");
|
||||
await expectContentType(page, "includeLive", true);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expectContentType(page, "includeLive", true);
|
||||
});
|
||||
|
||||
// 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 }) => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
|
||||
// Turn Live/Upcoming ON inside the channel view.
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await page.getByTestId("feed-content-includeLive").click();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await toggleContentType(page, "includeLive");
|
||||
await expectContentType(page, "includeLive", true);
|
||||
|
||||
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
|
||||
await page.getByTestId("channel-back").click();
|
||||
await expect(page.getByTestId("channel-title")).toHaveCount(0);
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
});
|
||||
|
||||
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
|
||||
|
||||
@@ -12,11 +12,14 @@ test.describe("feed", () => {
|
||||
await expect(count).toHaveText(/\d/);
|
||||
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("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 expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
|
||||
await expect(count).not.toHaveText(baseline);
|
||||
@@ -24,7 +27,7 @@ test.describe("feed", () => {
|
||||
// Remove the chip → default show restored, chips gone.
|
||||
await page.getByTestId("active-filter-chip").click();
|
||||
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 }) => {
|
||||
@@ -73,6 +76,7 @@ test.describe("feed", () => {
|
||||
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
|
||||
|
||||
// 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 expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,29 @@ export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
|
||||
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). */
|
||||
export async function openFirstVideo(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user