fix(ui): membership menus keep their lines beside the list and focus in the panel
The loading / failed-load lines and their Retry sat inside role=menu, which may own its items only: the three surfaces now open with `listInside` and the rows sit in a PopoverList. A keyboard Retry unmounted the focused button and left focus on <body> with the menu open, the arrows dead: focus is put back on the first row, else the field, else the new Retry. The channel tag editor said a failed tag load as "no tags yet"; it reads the catalog query's state now. The playlist panel's name and the tag editor's error state are held by tests.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
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 } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverList } from "./AnchoredPopover";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Input } from "./Input";
|
||||
import { MenuCheck } from "./MenuCheck";
|
||||
@@ -17,6 +17,13 @@ import { StateMessage } from "./QueryState";
|
||||
// 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;
|
||||
@@ -64,8 +71,28 @@ export function MembershipMenu({
|
||||
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;
|
||||
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;
|
||||
}, [status]);
|
||||
|
||||
async function submit() {
|
||||
const name = draft.trim();
|
||||
if (!name) return;
|
||||
@@ -86,7 +113,7 @@ export function MembershipMenu({
|
||||
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 className="flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<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}
|
||||
@@ -103,24 +130,26 @@ export function MembershipMenu({
|
||||
) : rows.length === 0 ? (
|
||||
<p className={`text-xs text-muted py-2 ${dense ? "px-2" : "px-1.5"}`}>{emptyText}</p>
|
||||
) : null}
|
||||
{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>
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user