From 5851ddd3290cab61c61a06f7d23505091fa331b5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 21 Aug 2026 17:28:57 +0200 Subject: [PATCH] feat(feed): compress the toolbar chip groups into labeled FilterSelect dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/e2e/channel.spec.ts | 36 ++--- frontend/e2e/feed.spec.ts | 12 +- frontend/e2e/helpers.ts | 23 +++ frontend/src/components/Feed.tsx | 50 +++---- .../src/components/FilterSelect.dom.test.tsx | 92 ++++++++++++ frontend/src/components/FilterSelect.tsx | 135 ++++++++++++++++++ 6 files changed, 289 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/FilterSelect.dom.test.tsx create mode 100644 frontend/src/components/FilterSelect.tsx diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index a2136228..71479b98 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -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 diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts index 937f667b..61c045d1 100644 --- a/frontend/e2e/feed.spec.ts +++ b/frontend/e2e/feed.spec.ts @@ -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); }); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index de3c4693..2a0a4204 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -33,6 +33,29 @@ export async function clickVisibleChannelLink(page: Page): Promise { 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 { + 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 { + 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 { await expect(page.getByTestId("video-card").first()).toBeVisible(); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 360487ed..0ba3d09a 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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 = (
- ({ - 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. */} + ({ value: id, label: t("sidebar.show." + id) }))} + selected={[filters.show]} + onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })} + /> + ({ 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); + }} /> -