Rows that just mounted join the menu's navigation list a render after the status effect ran: a row focused that early was unknown to it, and the arrows did nothing from it (seen live after a keyboard Retry). The retry test now arrows on from the row it lands on.
199 lines
7.8 KiB
TypeScript
199 lines
7.8 KiB
TypeScript
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 <body> 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 <body> 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<boolean>;
|
|
};
|
|
/** 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<HTMLDivElement | null>(null);
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
const [draft, setDraft] = useState("");
|
|
|
|
const shownStatus = useRef(status);
|
|
const placed = useRef<HTMLElement | null>(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<HTMLElement>("[role=menuitemcheckbox]") ??
|
|
root.querySelector<HTMLElement>("input") ??
|
|
root.querySelector<HTMLElement>("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<HTMLElement>("[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.
|
|
<div ref={rootRef} className="flex flex-col" onClick={(e) => e.stopPropagation()}>
|
|
{heading && <div className="text-xs text-muted px-1.5 pb-1.5">{heading}</div>}
|
|
<div
|
|
ref={setList}
|
|
style={fadeStyle}
|
|
aria-busy={busy || status === "loading"}
|
|
className={`${dense ? "max-h-60" : "max-h-56"} overflow-y-auto no-scrollbar`}
|
|
>
|
|
{status === "loading" ? (
|
|
<StateMessage compact>{t("common.loading")}</StateMessage>
|
|
) : status === "error" ? (
|
|
<StateMessage compact tone="error" onRetry={onRetry}>
|
|
{errorText}
|
|
</StateMessage>
|
|
) : rows.length === 0 ? (
|
|
<p className={`text-xs text-muted py-2 ${dense ? "px-2" : "px-1.5"}`}>{emptyText}</p>
|
|
) : null}
|
|
<PopoverList>
|
|
{status === "ready" &&
|
|
rows.map((row) => (
|
|
<PopoverItem
|
|
key={row.id}
|
|
// A thing can sit in several sets at once — checkboxes, not one choice.
|
|
role="menuitemcheckbox"
|
|
checked={row.checked}
|
|
onSelect={() => 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"
|
|
}`}
|
|
>
|
|
<MenuCheck kind="many" on={row.checked} />
|
|
<span className="truncate flex-1 text-left">{row.label}</span>
|
|
{row.extra}
|
|
</PopoverItem>
|
|
))}
|
|
</PopoverList>
|
|
</div>
|
|
{create && (
|
|
<div
|
|
className={`flex items-center border-t border-border ${dense ? "gap-1 mt-1" : "gap-1.5 mt-1.5"} pt-1.5`}
|
|
>
|
|
{!create.submitLabel && <Plus className="w-3.5 h-3.5 text-muted shrink-0" />}
|
|
<Input
|
|
className="flex-1"
|
|
size="sm"
|
|
value={draft}
|
|
onChange={(e) => 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 && (
|
|
<IconButton
|
|
variant="outline"
|
|
onClick={() => void submit()}
|
|
disabled={!draft.trim()}
|
|
label={create.submitLabel}
|
|
>
|
|
<Plus />
|
|
</IconButton>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|