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 { 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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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