Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | 54x 54x 54x 54x 268x 268x 268x 268x 268x 268x 19x 19x 268x 165x 22x 22x 22x 22x 268x 185x 44x 185x 13x 13x 185x 185x 185x 185x 185x 44x 44x 44x 268x 3x 3x 3x 3x 2x 3x 3x 268x 22x 90x 17x 17x | "use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface OverflowMenuItem {
label: string;
onSelect: () => void;
tone?: "default" | "danger";
disabled?: boolean;
}
interface OverflowMenuProps {
/** Accessible name of the ··· trigger, e.g. "More for photo 2". */
label: string;
items: OverflowMenuItem[];
className?: string;
/** A 28px visible chip inside the 44px hit area, for small surfaces (a thumbnail). */
compact?: boolean;
}
const MENU_WIDTH = 192; // w-48
const GAP = 4;
const EDGE = 8;
/** A ··· button that opens a small action menu. The menu is portalled to <body> with
* fixed positioning, so a scrolling or clipping ancestor (a thumbnail strip) never
* cuts it off. Keyboard: opens on the first item, ArrowUp/Down/Home/End move,
* Escape closes back to the trigger; Tab or a tap outside closes. */
export function OverflowMenu({ label, items, className, compact = false }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top?: number; bottom?: number; left: number } | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
const close = (refocus: boolean) => {
setOpen(false);
if (refocus) triggerRef.current?.focus();
};
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
const left = Math.max(EDGE, Math.min(r.right - MENU_WIDTH, window.innerWidth - MENU_WIDTH - EDGE));
const roughHeight = items.length * 44 + 16;
setPos(
r.bottom + GAP + roughHeight > window.innerHeight && r.top > roughHeight
? { bottom: window.innerHeight - r.top + GAP, left }
: { top: r.bottom + GAP, left },
);
}, [open, items.length]);
useEffect(() => {
if (!open) return;
menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus();
const onPointerDown = (e: PointerEvent) => {
const t = e.target as Node;
if (!menuRef.current?.contains(t) && !triggerRef.current?.contains(t)) close(false);
};
// Fixed position goes stale once anything scrolls; closing is simpler than tracking.
const onScrollOrResize = (e: Event) => {
if (!menuRef.current?.contains(e.target as Node)) close(false);
};
document.addEventListener("pointerdown", onPointerDown);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};
}, [open, pos]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const els = Array.from(
menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? [],
);
const at = els.indexOf(document.activeElement as HTMLElement);
const moveTo = (i: number) => els[(i + els.length) % els.length]?.focus();
// Stop handled keys here: React bubbles portal events to the trigger's ancestors
// (e.g. a carousel that also listens for arrow keys).
if (e.key === "Escape") close(true);
else if (e.key === "ArrowDown") moveTo(at + 1);
else Eif (e.key === "ArrowUp") moveTo(at - 1);
else if (e.key === "Home") moveTo(0);
else if (e.key === "End") moveTo(els.length - 1);
else if (e.key === "Tab") return close(true);
else return;
e.preventDefault();
e.stopPropagation();
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => (open ? close(false) : setOpen(true))}
className={`group inline-flex h-11 w-11 items-center justify-center rounded-full text-ink transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary ${compact ? "" : "border border-rim bg-glass hover:bg-glass-strong"} ${className ?? ""}`}
>
<span
aria-hidden
className={
compact
? "inline-flex h-7 w-7 items-center justify-center rounded-full border border-ink-soft bg-surface-raised text-sm leading-none shadow-lift transition-colors group-hover:bg-surface"
: "text-lg leading-none"
}
>
···
</span>
</button>
{open &&
createPortal(
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
style={{ position: "fixed", width: MENU_WIDTH, ...pos, visibility: pos ? "visible" : "hidden" }}
className="z-50 space-y-1 rounded-card bg-surface-raised p-2 shadow-raised ring-1 ring-hairline"
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
tabIndex={-1}
disabled={item.disabled}
onClick={() => {
close(true);
item.onSelect();
}}
className={`flex min-h-11 w-full items-center rounded-control px-3 text-left text-sm hover:bg-surface focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-50 ${item.tone === "danger" ? "text-error-deep" : "text-ink"}`}
>
{item.label}
</button>
))}
</div>,
document.body,
)}
</>
);
}
|