From 7e94da72b9bb9b8dca671c28f0534a0561e1173a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 21:18:48 +0200 Subject: [PATCH] fix(ui): one membership menu for playlists, collections and tags Three hand-copied panels had drifted: Add to playlist said a failed load as 'no playlists yet'. ui/MembershipMenu now holds them: a loading line and an error with Retry instead of a blank or an empty list, the up/down arrows step from the name field into rows that arrived after the menu opened, and the panels opened from a menu carry their own accessible name. A write that landed flips its tick at once, so a second tap before the refetch undoes it instead of repeating it; a collection edit also refreshes a show's own page. --- frontend/src/components/AddToPlaylist.tsx | 125 +++++-------- frontend/src/components/ChannelTags.tsx | 89 ++-------- .../PlaylistEditFailures.dom.test.tsx | 34 ++++ frontend/src/components/PlexCollectionAdd.tsx | 122 ++++++------- frontend/src/components/PlexMenu.dom.test.tsx | 100 ++++++++++- frontend/src/components/ui/MembershipMenu.tsx | 164 ++++++++++++++++++ frontend/src/components/ui/README.md | 2 +- 7 files changed, 416 insertions(+), 220 deletions(-) create mode 100644 frontend/src/components/ui/MembershipMenu.tsx diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index fd0c7582..6055a434 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -2,23 +2,19 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { ListPlus, Plus } from "lucide-react"; +import { ListPlus } from "lucide-react"; import { YouTubeIcon } from "../lib/brandIcons"; -import { MenuCheck } from "./ui/MenuCheck"; import { api, type Playlist } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; -import { useScrollFade } from "../lib/useScrollFade"; import { playlistName } from "../lib/playlistName"; -import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; +import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { IconButton } from "./ui/IconButton"; -import { Input } from "./ui/Input"; +import { MembershipMenu } from "./ui/MembershipMenu"; import { SURFACE } from "./ui/surfaces"; // A small anchored menu for toggling a video's membership in the user's playlists, with inline -// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style -// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and -// the input swallowing non-Escape keys, since character and arrow keys belong to the text field. +// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's). export default function AddToPlaylist({ videoId, variant = "ghost", @@ -94,9 +90,7 @@ function MembershipPanel({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - const { ref: fadeRef, style: fadeStyle } = useScrollFade(); const open = pop.open; - const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); const membership = useQuery({ @@ -115,13 +109,17 @@ function MembershipPanel({ // A failure is said, and the list refreshed either way. With no catch, a playlist deleted on // another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an - // unhandled rejection: the tick did not change, nothing was said, the gone row stayed. + // unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that + // landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it. async function toggle(pl: Playlist) { if (busy) return; setBusy(true); try { if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); else await api.addToPlaylist(pl.id, videoId); + qc.setQueryData(qk.playlistMembership(videoId), (old) => + old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)), + ); } catch (e) { notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); } @@ -129,19 +127,20 @@ function MembershipPanel({ setBusy(false); } - async function createAndAdd() { - const name = newName.trim(); - if (!name || busy) return; + async function createAndAdd(name: string) { + if (busy) return false; setBusy(true); + let made = false; try { const pl = await api.createPlaylist(name); await api.addToPlaylist(pl.id, videoId); - setNewName(""); + made = true; } catch (e) { notifyActionError(e, t("playlists.addFailed")); } refresh(); setBusy(false); + return made; } // All of the user's playlists are add targets, including YouTube-linked ones — adding @@ -149,67 +148,39 @@ function MembershipPanel({ const lists = membership.data ?? []; return ( - <> - - {/* A React portal still propagates events up the REACT tree, so a click inside this panel - would otherwise reach the feed card that hosts the trigger and start playback. */} -
e.stopPropagation()}> -
- {t("playlists.addToPlaylistTitle")} -
-
- {lists.length === 0 && !membership.isLoading && ( -
{t("playlists.noneYet")}
- )} - {lists.map((pl) => ( - toggle(pl)} - // NOT `disabled={busy}`: the native disabled attribute would blur the focused item to - // mid-request and permanently kill roving focus (useListNavigation listens on - // the floating element only). `toggle()` already no-ops while busy, so re-entrancy is - // guarded without touching focus; `aria-busy` on the list conveys the pending state. - label={playlistName(pl, t)} - className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition" - > - - {playlistName(pl, t)} - {(pl.source === "youtube" || pl.yt_playlist_id) && ( - - )} - - ))} -
-
- - setNewName(e.target.value)} - onKeyDown={(e) => { - // Escape bubbles to the popover's dismiss; every other key is text editing here and - // must not reach the surrounding menu's list navigation. - if (e.key === "Escape") return; - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - void createAndAdd(); - } - }} - placeholder={t("playlists.newPlaylist")} - /> -
-
-
- + + ({ + id: pl.id, + label: playlistName(pl, t), + checked: !!pl.has_video, + extra: + pl.source === "youtube" || pl.yt_playlist_id ? ( + + ) : undefined, + }))} + onToggle={(row) => { + const pl = lists.find((p) => p.id === row.id); + if (pl) void toggle(pl); + }} + status={ + membership.isError && !membership.data + ? "error" + : membership.isLoading + ? "loading" + : "ready" + } + onRetry={() => void membership.refetch()} + emptyText={t("playlists.noneYet")} + errorText={t("common.loadError")} + busy={busy} + create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }} + /> + ); } diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 81177e85..c78bffa4 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -1,17 +1,14 @@ import { useRef, useState } from "react"; import { Plus, Sparkles } from "lucide-react"; -import { MenuCheck } from "./ui/MenuCheck"; import { useTranslation } from "react-i18next"; import { type Tag } from "../lib/api"; -import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover"; +import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { useScrollFade } from "../lib/useScrollFade"; import { fitChipsWithPill } from "../lib/chipOverflow"; import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; -import { IconButton } from "./ui/IconButton"; import { Chip, DismissibleChip } from "./ui/Chip"; import { chipClass } from "./ui/chipStyles"; -import { Input } from "./ui/Input"; +import { MembershipMenu } from "./ui/MembershipMenu"; import { SURFACE } from "./ui/surfaces"; // The channel tag surface, shared by the channel manager (table + cards) and the channel detail @@ -194,8 +191,7 @@ function SuggestedChipsLane({ } /** The editor body: toggle the user's existing tags on/off for this channel, plus an inline - * "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel - * detail page in Part 2; internal for now.) */ + * "create + attach" input. Rendered inside an anchored `menu` popover. */ function TagEditorMenu({ userTags, attachedIds, @@ -208,71 +204,24 @@ function TagEditorMenu({ onCreate: (name: string) => void; }) { const { t } = useTranslation(); - const [draft, setDraft] = useState(""); - const { ref: fadeRef, style: fadeStyle } = useScrollFade(); const attached = new Set(attachedIds); - const submit = () => { - const name = draft.trim(); - if (!name) return; - onCreate(name); - setDraft(""); - }; return ( -
-
- {userTags.map((tg) => { - const on = attached.has(tg.id); - return ( - onToggle(tg.id)} - label={tg.name} - className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${ - on ? "text-fg" : "text-muted" - }`} - > - - {tg.name} - - ); - })} - {userTags.length === 0 && ( -

{t("channels.tags.noneYet")}

- )} -
-
- setDraft(e.target.value)} - onKeyDown={(e) => { - // Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key — - // arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's - // list-navigation/typeahead handlers on the surrounding surface. - if (e.key === "Escape") return; - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - submit(); - } - }} - maxLength={64} - placeholder={t("channels.tags.createPlaceholder")} - /> - - - -
-
+ ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))} + onToggle={(row) => onToggle(row.id as number)} + emptyText={t("channels.tags.noneYet")} + errorText={t("common.loadError")} + create={{ + placeholder: t("channels.tags.createPlaceholder"), + maxLength: 64, + submitLabel: t("channels.tags.create"), + onCreate: (name) => { + onCreate(name); + return true; + }, + }} + /> ); } diff --git a/frontend/src/components/PlaylistEditFailures.dom.test.tsx b/frontend/src/components/PlaylistEditFailures.dom.test.tsx index 34138017..041a3836 100644 --- a/frontend/src/components/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/components/PlaylistEditFailures.dom.test.tsx @@ -157,6 +157,40 @@ describe("the add-to-playlist popover", () => { await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); }); + it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => { + vi.spyOn(api, "playlists").mockRejectedValue(new Error("down")); + render( + + + , + ); + fireEvent.click(screen.getByLabelText("playlists.addToPlaylist")); + const alert = await screen.findByRole("alert", { hidden: true }); + expect(alert.textContent).toContain("common.loadError"); + expect(screen.queryByText("playlists.noneYet")).toBeNull(); + }); + + it("flips the tick as soon as an add lands, so the next tap takes it out again", async () => { + const lists = vi.spyOn(api, "playlists"); + lists.mockResolvedValueOnce([{ id: 3, name: "Mix", kind: "user", has_video: false }] as never); + // The refetch after the write never answers: the tick must not wait for it. + lists.mockImplementation(() => new Promise(() => {})); + const add = vi.spyOn(api, "addToPlaylist").mockResolvedValue({} as never); + const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({} as never); + render( + + + , + ); + fireEvent.click(screen.getByLabelText("playlists.addToPlaylist")); + const row = await screen.findByRole("menuitemcheckbox", { hidden: true }); + fireEvent.click(row); + await waitFor(() => expect(row.getAttribute("aria-checked")).toBe("true")); + fireEvent.click(row); + await waitFor(() => expect(remove).toHaveBeenCalledWith(3, "v1")); + expect(add).toHaveBeenCalledTimes(1); + }); + it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => { // Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had // no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating diff --git a/frontend/src/components/PlexCollectionAdd.tsx b/frontend/src/components/PlexCollectionAdd.tsx index 76dd8dc5..403d5f62 100644 --- a/frontend/src/components/PlexCollectionAdd.tsx +++ b/frontend/src/components/PlexCollectionAdd.tsx @@ -1,23 +1,19 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type PlexCollection } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; -import { useScrollFade } from "../lib/useScrollFade"; -import { MenuCheck } from "./ui/MenuCheck"; -import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; +import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { Input } from "./ui/Input"; +import { MembershipMenu } from "./ui/MembershipMenu"; import { SURFACE } from "./ui/surfaces"; // "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title // into or out of its library's manual collections, and names a new one — created with this title in // it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new -// collection, its name, a search for the title, then another search to add the next one. The panel is -// the video card's "Add to playlist" (AddToPlaylist), so it keeps that file's two conventions: -// typeahead off, and the name field keeping every key but Escape to itself. +// collection, its name, a search for the title, then another search to add the next one. Its body is +// the same MembershipMenu as the video card's "Add to playlist". export interface PlexCollectionTarget { ratingKey: string; @@ -43,29 +39,33 @@ export default function PlexCollectionAddAt({ }); const { setPositionReference } = pop.refs; useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]); - const { ref: fadeRef, style: fadeStyle } = useScrollFade(); - const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); const { ratingKey } = target; + const forKey = qk.plexCollections("for", ratingKey); const list = useQuery({ - queryKey: qk.plexCollections("for", ratingKey), + queryKey: forKey, queryFn: () => api.plexCollectionsFor(ratingKey), }); - // The title's own page, the collections' pages and the library grid all show membership. + // The title's own page (a movie's item, a show's show), the collections' pages and the library grid + // all show membership. function refresh() { void qc.invalidateQueries({ queryKey: qk.plexCollections() }); void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) }); + void qc.invalidateQueries({ queryKey: qk.plexShow(ratingKey) }); void qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } + // A write that landed flips the tick at once: the row's state comes from the list, and a second tap + // before its refetch returned would otherwise repeat the write instead of undoing it. async function toggle(c: PlexCollection & { contains: boolean }) { if (busy) return; setBusy(true); try { if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey); else await api.plexCollectionAddItem(c.id, ratingKey); + qc.setQueryData(forKey, (old) => flipContains(old, c.id)); } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } @@ -73,76 +73,58 @@ export default function PlexCollectionAddAt({ setBusy(false); } - async function create() { - const name = newName.trim(); - if (!name || busy) return; + async function create(name: string) { + if (busy) return false; setBusy(true); + let made = false; try { // No library: the server takes the seed title's own. await api.plexCreateCollection("", name, ratingKey); - setNewName(""); + made = true; } catch (e) { notifyActionError(e, t("plex.collection.editFailed")); } refresh(); setBusy(false); + return made; } - const rows = list.data?.collections ?? []; + const collections = list.data?.collections ?? []; return ( - - {/* A portal still bubbles through the React tree: keep a click here off the card that opened it. */} -
e.stopPropagation()}> -
{t("plex.collectionAdd.title")}
-
- {list.isError ? ( -
- {t("plex.collectionAdd.loadFailed")} -
- ) : rows.length === 0 && !list.isLoading ? ( -
{t("plex.collectionAdd.none")}
- ) : null} - {rows.map((c) => ( - void toggle(c)} - // Not `disabled={busy}`: that blurs the focused row and ends roving focus (see - // AddToPlaylist); toggle() no-ops while busy and the list says aria-busy. - label={c.title} - className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition" - > - - {c.title} - - ))} -
-
- - setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") return; - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - void create(); - } - }} - placeholder={t("plex.collection.newCollection")} - aria-label={t("plex.collection.namePlaceholder")} - /> -
-
+ + ({ id: c.id, label: c.title, checked: c.contains }))} + onToggle={(row) => { + const c = collections.find((x) => x.id === row.id); + if (c) void toggle(c); + }} + status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"} + onRetry={() => void list.refetch()} + emptyText={t("plex.collectionAdd.none")} + errorText={t("plex.collectionAdd.loadFailed")} + busy={busy} + create={{ + placeholder: t("plex.collection.newCollection"), + ariaLabel: t("plex.collection.namePlaceholder"), + onCreate: create, + }} + /> ); } + +function flipContains( + old: T | undefined, + id: string, +): T | undefined { + if (!old) return old; + return { + ...old, + collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)), + }; +} diff --git a/frontend/src/components/PlexMenu.dom.test.tsx b/frontend/src/components/PlexMenu.dom.test.tsx index 1b93587c..90424624 100644 --- a/frontend/src/components/PlexMenu.dom.test.tsx +++ b/frontend/src/components/PlexMenu.dom.test.tsx @@ -113,7 +113,7 @@ function setup( onToggleWatched: vi.fn(), onRestart: vi.fn(), }; - const qc = new QueryClient(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); if (admin) qc.setQueryData(qk.me(), { role: "admin" }); render( @@ -122,7 +122,7 @@ function setup( , ); - return { handlers }; + return { handlers, qc }; } // The card root holds the menu's trigger props (right-click anywhere on it). const rightClick = () => @@ -288,4 +288,100 @@ describe("a Plex title's menu — Add to collection", () => { fireEvent.keyDown(name, { key: "Enter" }); await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1")); }); + + const checkRow = async (name: string) => + vi.waitFor(() => { + const found = screen + .queryAllByRole("menuitemcheckbox", { hidden: true }) + .find((b) => b.textContent === name); + expect(found).toBeDefined(); + return found!; + }); + const idle = () => + vi.waitFor(() => + expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"), + ); + + it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => { + const list = vi.spyOn(api, "plexCollectionsFor"); + list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] }); + // The refetch after the write never answers: the tick must not wait for it. + list.mockImplementation(() => new Promise(() => {})); + const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection); + const remove = vi + .spyOn(api, "plexCollectionRemoveItem") + .mockResolvedValue({} as PlexCollection); + setup(card({}), {}, { admin: true }); + rightClick(); + fireEvent.click(item("plex.collectionAdd.row")); + fireEvent.click(await checkRow("Beta")); + await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1")); + await idle(); + expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true"); + fireEvent.click(await checkRow("Beta")); + await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1")); + expect(add).toHaveBeenCalledTimes(1); + }); + + it("refreshes a show's own page too, whose collections come with the show", async () => { + vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ + collections: [coll("cB", "Beta", false)], + }); + vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection); + const { qc } = setup(card({ type: "show" }), {}, { admin: true }); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + rightClick(); + fireEvent.click(item("plex.collectionAdd.row")); + fireEvent.click(await checkRow("Beta")); + await idle(); + expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") }); + }); + + it("is a named menu that says it is loading, not that there are no collections", async () => { + vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {})); + setup(card({}), {}, { admin: true }); + rightClick(); + fireEvent.click(item("plex.collectionAdd.row")); + // jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it. + const menu = await vi.waitFor(() => { + const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]'); + expect(found).not.toBeNull(); + return found!; + }); + expect(menu.hasAttribute("aria-labelledby")).toBe(false); + expect(screen.getByText("common.loading")).toBeTruthy(); + expect(screen.queryByText("plex.collectionAdd.none")).toBeNull(); + }); + + it("says a failed load as one, with a retry — not as no collections", async () => { + const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down")); + setup(card({}), {}, { admin: true }); + rightClick(); + fireEvent.click(item("plex.collectionAdd.row")); + const alert = await screen.findByRole("alert", { hidden: true }); + expect(alert.textContent).toContain("plex.collectionAdd.loadFailed"); + expect(screen.queryByText("plex.collectionAdd.none")).toBeNull(); + list.mockResolvedValue({ collections: [coll("cB", "Beta", false)] }); + fireEvent.click(await screen.findByText("common.retry")); + await checkRow("Beta"); + }); + + it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => { + let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {}; + vi.spyOn(api, "plexCollectionsFor").mockImplementation( + () => new Promise((resolve) => (answer = resolve)), + ); + setup(card({}), {}, { admin: true }); + rightClick(); + fireEvent.click(item("plex.collectionAdd.row")); + const name = await screen.findByLabelText("plex.collection.namePlaceholder"); + name.focus(); + answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] }); + const alpha = await checkRow("Alpha"); + fireEvent.keyDown(name, { key: "ArrowDown" }); + expect(document.activeElement).toBe(alpha); + name.focus(); + fireEvent.keyDown(name, { key: "ArrowUp" }); + expect(document.activeElement).toBe(await checkRow("Beta")); + }); }); diff --git a/frontend/src/components/ui/MembershipMenu.tsx b/frontend/src/components/ui/MembershipMenu.tsx new file mode 100644 index 00000000..caa3a8b0 --- /dev/null +++ b/frontend/src/components/ui/MembershipMenu.tsx @@ -0,0 +1,164 @@ +import { useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus } from "lucide-react"; +import { useScrollFade } from "../../lib/useScrollFade"; +import { PopoverItem } from "./AnchoredPopover"; +import { IconButton } from "./IconButton"; +import { Input } from "./Input"; +import { MenuCheck } from "./MenuCheck"; +import { StateMessage } from "./QueryState"; + +// The body of a "which of these is it in" menu: a checkbox row per set (a playlist, a collection, a +// tag), and a field that names a new one. Rendered inside an anchored `menu` surface — the video +// card's "Add to playlist", a Plex title's "Add to collection", a channel's tags. One body, so a fix +// to its states or keys reaches all three: the copies had drifted (one said a failed load as "none"). +// +// The surface around it is opened with `hostsInput: true` (no typeahead, no focus on hover), and the +// name field keeps its keys to itself — except Escape, which closes the menu, and the up/down arrows, +// which step from the field into the rows: the rows may arrive after the surface opened, when the +// popover's own first-row focus has already given up and left the focus in the field. + +export interface MembershipRow { + id: string | number; + label: string; + checked: boolean; + /** A mark after the label (the YouTube glyph on a linked playlist). */ + extra?: ReactNode; +} + +export function MembershipMenu({ + heading, + rows, + onToggle, + status = "ready", + onRetry, + emptyText, + errorText, + busy = false, + create, + dense = false, +}: { + heading?: string; + rows: MembershipRow[]; + onToggle: (row: MembershipRow) => void; + /** The rows' load: `loading` and `error` say so instead of an empty list. */ + status?: "loading" | "error" | "ready"; + onRetry?: () => void; + emptyText: string; + errorText: string; + /** A write is in flight: said on the list (`aria-busy`). The rows stay enabled — a disabled button + * blurs the focused row to mid-request and ends roving focus; the caller no-ops instead. */ + busy?: boolean; + create?: { + placeholder: string; + ariaLabel?: string; + maxLength?: number; + /** With a label, a trailing button submits too; without, a leading + marks the field. */ + submitLabel?: string; + /** Resolves true when the field should clear (the new set was made). */ + onCreate: (name: string) => boolean | Promise; + }; + /** The smaller rows of a menu inside a table cell (a channel's tags). */ + dense?: boolean; +}) { + const { t } = useTranslation(); + const { ref: fadeRef, style: fadeStyle } = useScrollFade(); + const listRef = useRef(null); + const [draft, setDraft] = useState(""); + + async function submit() { + const name = draft.trim(); + if (!name) return; + if (await create?.onCreate(name)) setDraft(""); + } + + function intoRows(key: string) { + const items = listRef.current?.querySelectorAll("[role=menuitemcheckbox]"); + const row = key === "ArrowDown" ? items?.[0] : items?.[items.length - 1]; + row?.focus(); + } + + const setList = (node: HTMLDivElement | null) => { + listRef.current = node; + fadeRef(node); + }; + + const rowText = dense ? "text-xs px-2" : "text-sm px-1.5"; + return ( + // A portal still bubbles through the React tree: keep a click here off the card that opened it. +
e.stopPropagation()}> + {heading &&
{heading}
} +
+ {status === "loading" ? ( + {t("common.loading")} + ) : status === "error" ? ( + + {errorText} + + ) : rows.length === 0 ? ( +

{emptyText}

+ ) : null} + {status === "ready" && + rows.map((row) => ( + onToggle(row)} + label={row.label} + className={`w-full flex items-center gap-2 ${rowText} py-1.5 rounded-lg outline-hidden transition ${ + dense && !row.checked ? "text-muted" : "text-fg" + }`} + > + + {row.label} + {row.extra} + + ))} +
+ {create && ( +
+ {!create.submitLabel && } + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") return; + e.stopPropagation(); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + intoRows(e.key); + } else if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + maxLength={create.maxLength} + placeholder={create.placeholder} + aria-label={create.ariaLabel} + /> + {create.submitLabel && ( + void submit()} + disabled={!draft.trim()} + label={create.submitLabel} + > + + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 417a6166..8b943111 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -37,7 +37,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage | area | components | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | +| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `