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.
This commit is contained in:
@@ -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 <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 [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<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 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}
|
||||
{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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
|
||||
Reference in New Issue
Block a user