The reason for a disabled menu row lived in a `title` on a natively disabled button: no pointer events, so no tooltip, and roving focus skips the row — four translated sentences reached nobody (the Watch later rename/delete lock, "no other list to move to", the reorder gate). An aria-disabled row was tried and reverted in E10's review. ActionMenu items now take a `note`, rendered as a muted line under a disabled row's label: seen, and readable in a screen reader's browse mode. Only the icon and label dim — row-level opacity would dim the explanation too. The reorder group states its reason once, under its first row.
231 lines
9.3 KiB
TypeScript
231 lines
9.3 KiB
TypeScript
import { Fragment, useState, type ReactNode } from "react";
|
||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
|
||
|
||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||
//
|
||
// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
|
||
// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point
|
||
// of the split: a context menu that drifts from the overflow menu beside it is two menus, and the
|
||
// user has to learn both.
|
||
|
||
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
|
||
* `ContextMenu`. One constant so a context menu can never look like a different component. */
|
||
export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]";
|
||
// No `disabled:opacity-50` on the row itself: a disabled row may carry a `note`, and opacity on the
|
||
// row would dim the one line that explains it. The icon and the label dim instead (`dim`).
|
||
const ROW =
|
||
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition";
|
||
const dim = (disabled: boolean | undefined) => (disabled ? " opacity-50" : "");
|
||
|
||
/** The label, and under it — on a disabled row only — the reason it is off. */
|
||
function RowLabel({ item }: { item: MenuItemBase }) {
|
||
return (
|
||
<span className="min-w-0 flex-1">
|
||
<span className={`block truncate${dim(item.disabled)}`}>{item.label}</span>
|
||
{item.disabled && item.note && (
|
||
<span className="block text-2xs leading-snug text-muted">{item.note}</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
interface MenuItemBase {
|
||
id: string;
|
||
label: string;
|
||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||
disabled?: boolean;
|
||
/** Optional native tooltip (carries a longer hint the label alone can't) — for an ENABLED row. */
|
||
title?: string;
|
||
/** Why a DISABLED row is off, shown as a line under its label. Not a `title`: a disabled button
|
||
* fires no pointer events (no tooltip) and roving focus skips it, so a reason there reached
|
||
* nobody. An `aria-disabled` row was tried and reverted (E10 review rounds 1–2: floating-ui skips
|
||
* it identically, and the mouse got a highlight that then no-opped). Visible text is the one
|
||
* place a disabled row's reason can be: seen, and read in a screen reader's browse mode. */
|
||
note?: string;
|
||
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
|
||
separatorBefore?: boolean;
|
||
}
|
||
|
||
interface ActionItem extends MenuItemBase {
|
||
onSelect: () => void;
|
||
/** Destructive action: painted in the danger colour, like the outline-danger buttons. */
|
||
danger?: boolean;
|
||
items?: never;
|
||
}
|
||
|
||
interface SubmenuItem extends MenuItemBase {
|
||
/** Non-empty → this row opens a submenu instead of firing. */
|
||
items: readonly ActionMenuItem[];
|
||
onSelect?: never;
|
||
}
|
||
|
||
// A discriminated union rather than "onSelect and items are both optional": the invariant is
|
||
// "a row either fires or it opens a submenu, never both and never neither", and this codebase's
|
||
// house rule is that such a rule lives in the type, not in a comment above it.
|
||
export type ActionMenuItem = ActionItem | SubmenuItem;
|
||
|
||
const isSubmenu = (it: ActionMenuItem): it is SubmenuItem => Array.isArray(it.items);
|
||
|
||
/** The rows of a menu: leaf actions, submenus and separators, from one item array. Rendered inside a
|
||
* `PopoverSurface` (which supplies the list registry) — see `ActionMenu` and `ContextMenu`.
|
||
* `onClose` closes the ROOT menu, so selecting deep inside a submenu dismisses the whole stack. */
|
||
export function MenuItemList({
|
||
items,
|
||
onClose,
|
||
}: {
|
||
items: readonly ActionMenuItem[];
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
{items.map((it, i) => (
|
||
<Fragment key={it.id}>
|
||
{it.separatorBefore && i > 0 && (
|
||
<div role="separator" className="my-1 h-px bg-border" aria-hidden="true" />
|
||
)}
|
||
{isSubmenu(it) ? (
|
||
<SubMenu item={it} onClose={onClose} />
|
||
) : (
|
||
<PopoverItem
|
||
role="menuitem"
|
||
disabled={it.disabled}
|
||
label={it.label}
|
||
title={it.title}
|
||
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
|
||
onSelect={() => closeThenAct(onClose, it.onSelect)}
|
||
className={`${ROW}${it.danger ? " text-danger" : ""}`}
|
||
>
|
||
{it.icon && (
|
||
<span
|
||
className={`shrink-0 ${it.danger ? "text-danger" : "text-muted"}${dim(it.disabled)}`}
|
||
>
|
||
{it.icon}
|
||
</span>
|
||
)}
|
||
<RowLabel item={it} />
|
||
</PopoverItem>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** One row that opens a nested menu. It is a real `PopoverItem`, so the parent's roving focus and
|
||
* typeahead reach it; the nested popover anchors to that same button.
|
||
*
|
||
* Why it nests correctly with no extra wiring: this component renders INSIDE the parent's
|
||
* `PopoverSurface`, i.e. inside its `<FloatingNode>`, so `useFloatingNodeId()` picks the parent up
|
||
* as its tree parent. That is what makes Floating UI's outside-press tree-aware — a press inside
|
||
* the submenu is not "outside" the parent, and Escape closes the topmost layer only. */
|
||
function SubMenu({ item, onClose }: { item: SubmenuItem; onClose: () => void }) {
|
||
const [open, setOpen] = useState(false);
|
||
const pop = useAnchoredPopover({
|
||
open,
|
||
onOpenChange: setOpen,
|
||
placement: "right-start",
|
||
offset: 2,
|
||
// A mouse expects the submenu to fall open under the pointer; a finger has no hover and taps.
|
||
hoverAlso: true,
|
||
// Target lists can be long (every playlist you own), so shrink to the viewport instead of
|
||
// running off the bottom at 768px.
|
||
fitHeight: true,
|
||
});
|
||
return (
|
||
<>
|
||
<PopoverItem
|
||
elementRef={pop.refs.setReference}
|
||
{...pop.getReferenceProps()}
|
||
role="menuitem"
|
||
disabled={item.disabled}
|
||
label={item.label}
|
||
title={item.title}
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
onKeyDown={(e) => {
|
||
// The native-menu keys. Enter/Space already open it via useClick; ArrowRight is the one
|
||
// the click handler doesn't cover.
|
||
if (e.key === "ArrowRight") {
|
||
e.preventDefault();
|
||
setOpen(true);
|
||
}
|
||
}}
|
||
className={ROW}
|
||
>
|
||
{item.icon && (
|
||
<span className={`shrink-0 text-muted${dim(item.disabled)}`}>{item.icon}</span>
|
||
)}
|
||
<RowLabel item={item} />
|
||
<ChevronRight className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||
</PopoverItem>
|
||
<PopoverSurface
|
||
popover={pop}
|
||
ariaLabel={item.label}
|
||
className={`${MENU_SURFACE} overflow-y-auto`}
|
||
style={{ maxHeight: "min(24rem, var(--fit-max-h))" }}
|
||
>
|
||
{/* ArrowLeft steps back out to the parent row — Escape would close the whole stack, and a
|
||
submenu you can only leave by dismissing everything is not a submenu. Focus returns to
|
||
the trigger via the surface's own returnFocus. */}
|
||
<div
|
||
onKeyDown={(e) => {
|
||
if (e.key === "ArrowLeft") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setOpen(false);
|
||
}
|
||
}}
|
||
>
|
||
<MenuItemList items={item.items} onClose={onClose} />
|
||
</div>
|
||
</PopoverSurface>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function ActionMenu({
|
||
items,
|
||
ariaLabel,
|
||
className,
|
||
trigger,
|
||
}: {
|
||
items: readonly ActionMenuItem[];
|
||
/** Accessible name for the trigger (e.g. "Catalog actions"). */
|
||
ariaLabel: string;
|
||
/** Trigger className override; omit for the default glass ⋯ icon button. */
|
||
className?: string;
|
||
/** Custom trigger content; omit for the default ⋯ icon. */
|
||
trigger?: ReactNode;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
|
||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
ref={pop.refs.setReference}
|
||
{...pop.getReferenceProps()}
|
||
aria-label={ariaLabel}
|
||
title={ariaLabel}
|
||
className={
|
||
className ??
|
||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||
}
|
||
>
|
||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||
</button>
|
||
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
|
||
<MenuItemList items={items} onClose={() => setOpen(false)} />
|
||
</PopoverSurface>
|
||
</>
|
||
);
|
||
}
|