UI Library

Menu popover

An anchored dropdown that closes on outside click and Escape, and flips above the trigger when there is no room below. Arrow keys move through items, so it works without a mouse.

Overlaypopoverdropdownmenukeyboard
Preview
apps/site/blocks/components/MenuPopover.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown, LogOut, Settings, User } from 'lucide-react';

export interface MenuItem {
  id: string;
  label: string;
  icon?: 'user' | 'settings' | 'logout';
  checked?: boolean;
}

const ICONS = { user: User, settings: Settings, logout: LogOut } as const;

const DEFAULT_ITEMS: MenuItem[] = [
  { id: 'profile', label: 'Profile', icon: 'user' },
  { id: 'settings', label: 'Settings', icon: 'settings', checked: true },
  { id: 'signout', label: 'Sign out', icon: 'logout' },
];

export function MenuPopover({
  label = 'Account',
  items = DEFAULT_ITEMS,
  onSelect,
}: {
  label?: string;
  items?: MenuItem[];
  onSelect?: (id: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const [flip, setFlip] = useState(false);
  const [active, setActive] = useState(0);
  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (!open) return;

    // Flip above the trigger when the menu would run off the bottom. Measured
    // rather than assumed, so it behaves near the fold as well as mid-page.
    const rect = triggerRef.current?.getBoundingClientRect();
    if (rect) {
      const needed = items.length * 36 + 16;
      setFlip(rect.bottom + needed > window.innerHeight && rect.top > needed);
    }

    function onPointerDown(event: MouseEvent) {
      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
    }
    function onKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        setOpen(false);
        triggerRef.current?.focus();
      } else if (event.key === 'ArrowDown') {
        event.preventDefault();
        setActive((i) => (i + 1) % items.length);
      } else if (event.key === 'ArrowUp') {
        event.preventDefault();
        setActive((i) => (i - 1 + items.length) % items.length);
      }
    }

    document.addEventListener('mousedown', onPointerDown);
    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('mousedown', onPointerDown);
      document.removeEventListener('keydown', onKeyDown);
    };
  }, [open, items.length]);

  return (
    <div ref={rootRef} className="relative inline-block text-left">
      <button
        ref={triggerRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        onClick={() => {
          setOpen((o) => !o);
          setActive(0);
        }}
        className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm font-medium transition-colors hover:bg-accent"
      >
        {label}
        <ChevronDown
          className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
        />
      </button>

      {open ? (
        <div
          role="menu"
          aria-label={label}
          className={`absolute right-0 z-40 w-52 rounded-lg border border-border bg-popover p-1 shadow-lg ${
            flip ? 'bottom-full mb-1' : 'top-full mt-1'
          }`}
        >
          {items.map((item, i) => {
            const Icon = item.icon ? ICONS[item.icon] : null;
            return (
              <button
                key={item.id}
                type="button"
                role="menuitem"
                onMouseEnter={() => setActive(i)}
                onClick={() => {
                  setOpen(false);
                  onSelect?.(item.id);
                }}
                className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${
                  i === active ? 'bg-accent text-foreground' : 'text-muted-foreground'
                }`}
              >
                {Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
                <span className="flex-1">{item.label}</span>
                {item.checked ? <Check className="h-3.5 w-3.5 shrink-0 text-primary" /> : null}
              </button>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

Dependencies

  • lucide-react — npm package

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.