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{emptyText}
) : null}