import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Plus } from "lucide-react"; import { useScrollFade } from "../../lib/useScrollFade"; import { PopoverItem, PopoverList } 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. // // The surface is opened with `listInside` too: the menu role sits on the rows' own box, and the lines // about them (loading, a failed load with its Retry, none yet), the heading and the field sit beside // it — a menu may own its items only. And when the state line swaps for the rows (or back), focus that // was on its Retry would be left on with the panel still open, the arrows dead: it is put back // in the panel — on the first row, else the field, else the new Retry. A spot it was put on only // because the rows were not there yet (the field, while a retry loads) hands on to the first row. 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 rootRef = useRef(null); const [draft, setDraft] = useState(""); const shownStatus = useRef(status); const placed = useRef(null); useEffect(() => { if (shownStatus.current === status) return; shownStatus.current = status; // A frame later: rows that just mounted join the menu's list a render after this effect, and a // row focused before that is not known to it — the arrows then did nothing from it. const frame = requestAnimationFrame(() => { const root = rootRef.current; const at = document.activeElement; // …unless a name is being typed into it meanwhile. const parked = at === placed.current && !(at instanceof HTMLInputElement && at.value); const lost = at == null || at === document.body || parked; if (!root || !lost) return; const target = root.querySelector("[role=menuitemcheckbox]") ?? root.querySelector("input") ?? root.querySelector("button"); target?.focus(); placed.current = target; }); return () => cancelAnimationFrame(frame); }, [status]); 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} > )}
)}
); }